Initial commit
This commit is contained in:
84
application/Espo/Tools/Stream/Api/DeleteFollowers.php
Normal file
84
application/Espo/Tools/Stream/Api/DeleteFollowers.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Tools\Stream\FollowerRecordService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class DeleteFollowers implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private FollowerRecordService $service,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$entityType = $request->getRouteParam('entityType');
|
||||
$id = $request->getRouteParam('id');
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
if (!$entityType || !$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
if (!$this->acl->check($entityType)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$ids = $data->ids ?? (isset($data->id) ? [$data->id] : []);
|
||||
|
||||
if ($ids === [] || !is_array($ids)) {
|
||||
throw new BadRequest("No ids.");
|
||||
}
|
||||
|
||||
foreach ($ids as $userId) {
|
||||
if (!is_string($userId)) {
|
||||
throw new BadRequest("Bad id item.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($ids as $userId) {
|
||||
$this->service->unlink($entityType, $id, $userId);
|
||||
}
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
}
|
||||
62
application/Espo/Tools/Stream/Api/DeleteMyReactions.php
Normal file
62
application/Espo/Tools/Stream/Api/DeleteMyReactions.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Record\EntityProvider;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Tools\Stream\MyReactionsService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class DeleteMyReactions implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityProvider $entityProvider,
|
||||
private MyReactionsService $myReactionsService,
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id') ?? throw new BadRequest();
|
||||
$type = $request->getRouteParam('type') ?? throw new BadRequest();
|
||||
|
||||
$note = $this->entityProvider->getByClass(Note::class, $id);
|
||||
|
||||
$this->myReactionsService->unReact($note, $type);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
}
|
||||
114
application/Espo/Tools/Stream/Api/DeleteNotePin.php
Normal file
114
application/Espo/Tools/Stream/Api/DeleteNotePin.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class DeleteNotePin implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id');
|
||||
|
||||
if (!$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$note = $this->getNote($id);
|
||||
|
||||
$this->checkParent($note);
|
||||
|
||||
if ($note->isPinned()) {
|
||||
$note->setIsPinned(false);
|
||||
$this->entityManager->saveEntity($note);
|
||||
}
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getNote(string $id): Note
|
||||
{
|
||||
$note = $this->entityManager->getRDBRepositoryByClass(Note::class)->getById($id);
|
||||
|
||||
if (!$note) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityRead($note)) {
|
||||
throw new Forbidden("No read access.");
|
||||
}
|
||||
|
||||
return $note;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkParent(Note $note): void
|
||||
{
|
||||
if (!$note->getParentType() || !$note->getParentId()) {
|
||||
throw new Forbidden("No parent.");
|
||||
}
|
||||
|
||||
$parent = $this->entityManager->getEntityById($note->getParentType(), $note->getParentId());
|
||||
|
||||
if (!$parent) {
|
||||
throw new Forbidden("Parent not found.");
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($parent)) {
|
||||
throw new Forbidden("No parent edit access.");
|
||||
}
|
||||
}
|
||||
}
|
||||
72
application/Espo/Tools/Stream/Api/GetFollowers.php
Normal file
72
application/Espo/Tools/Stream/Api/GetFollowers.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Record\SearchParamsFetcher;
|
||||
use Espo\Tools\Stream\FollowerRecordService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class GetFollowers implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private FollowerRecordService $service,
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$entityType = $request->getRouteParam('entityType');
|
||||
$id = $request->getRouteParam('id');
|
||||
|
||||
if (!$entityType || !$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
if (!$this->acl->check($entityType)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$searchParams = $this->searchParamsFetcher->fetch($request);
|
||||
|
||||
$collection = $this->service->find($entityType, $id, $searchParams);
|
||||
|
||||
return ResponseComposer::json($collection->toApiOutput());
|
||||
}
|
||||
}
|
||||
107
application/Espo/Tools/Stream/Api/GetGlobal.php
Normal file
107
application/Espo/Tools/Stream/Api/GetGlobal.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Record\SearchParamsFetcher;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Core\Select\Where\Item as WhereItem;
|
||||
use Espo\Tools\Stream\GlobalRecordService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class GetGlobal implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private GlobalRecordService $service
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
if (!$this->acl->checkScope(GlobalRecordService::SCOPE_NAME)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$searchParams = $this->fetchSearchParams($request);
|
||||
|
||||
$result = $this->service->find($searchParams);
|
||||
|
||||
return ResponseComposer::json($result->toApiOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function fetchSearchParams(Request $request): SearchParams
|
||||
{
|
||||
$searchParams = $this->searchParamsFetcher->fetch($request);
|
||||
|
||||
$after = $request->getQueryParam('after');
|
||||
|
||||
if ($after) {
|
||||
$searchParams = $searchParams
|
||||
->withWhereAdded(
|
||||
WhereItem
|
||||
::createBuilder()
|
||||
->setAttribute(Field::CREATED_AT)
|
||||
->setType(WhereItem\Type::AFTER)
|
||||
->setValue($after)
|
||||
->build()
|
||||
);
|
||||
}
|
||||
|
||||
$beforeNumber = $request->getQueryParam('beforeNumber');
|
||||
|
||||
if ($beforeNumber) {
|
||||
$searchParams = $searchParams
|
||||
->withWhereAdded(
|
||||
WhereItem
|
||||
::createBuilder()
|
||||
->setAttribute('number')
|
||||
->setType(WhereItem\Type::LESS_THAN)
|
||||
->setValue($beforeNumber)
|
||||
->build()
|
||||
);
|
||||
}
|
||||
|
||||
return $searchParams;
|
||||
}
|
||||
}
|
||||
106
application/Espo/Tools/Stream/Api/GetNoteReactors.php
Normal file
106
application/Espo/Tools/Stream/Api/GetNoteReactors.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Record\EntityProvider;
|
||||
use Espo\Core\Record\SearchParamsFetcher;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserReaction;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Condition;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class GetNoteReactors implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityProvider $entityProvider,
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private EntityManager $entityManager,
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id') ?? throw new BadRequest();
|
||||
$type = $request->getRouteParam('type') ?? throw new BadRequest();
|
||||
|
||||
$note = $this->entityProvider->getByClass(Note::class, $id);
|
||||
$searchParams = $this->searchParamsFetcher->fetch($request);
|
||||
|
||||
$query = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(User::ENTITY_TYPE)
|
||||
->withSearchParams($searchParams)
|
||||
->withStrictAccessControl()
|
||||
->withDefaultOrder()
|
||||
->buildQueryBuilder()
|
||||
->select([
|
||||
'id',
|
||||
'name',
|
||||
'userName',
|
||||
])
|
||||
->where(
|
||||
Condition::in(
|
||||
Expression::column('id'),
|
||||
SelectBuilder::create()
|
||||
->from(UserReaction::ENTITY_TYPE)
|
||||
->select('userId')
|
||||
->where([
|
||||
'type' => $type,
|
||||
'parentId' => $note->getId(),
|
||||
'parentType' => $note->getEntityType(),
|
||||
])
|
||||
->build()
|
||||
)
|
||||
)
|
||||
->build();
|
||||
|
||||
$repository = $this->entityManager->getRDBRepositoryByClass(User::class);
|
||||
|
||||
$users = $repository->clone($query)->find();
|
||||
$count = $repository->clone($query)->count();
|
||||
|
||||
return ResponseComposer::json([
|
||||
'list' => $users->getValueMapList(),
|
||||
'total' => $count,
|
||||
]);
|
||||
}
|
||||
}
|
||||
75
application/Espo/Tools/Stream/Api/GetOwn.php
Normal file
75
application/Espo/Tools/Stream/Api/GetOwn.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Record\SearchParamsFetcher;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Tools\Stream\UserRecordService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class GetOwn implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private UserRecordService $service
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$userId = $request->getRouteParam('id');
|
||||
|
||||
if (!$userId) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$searchParams = $this->fetchSearchParams($request);
|
||||
|
||||
$collection = $this->service->findOwn($userId, $searchParams);
|
||||
|
||||
return ResponseComposer::json($collection->toApiOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function fetchSearchParams(Request $request): SearchParams
|
||||
{
|
||||
return $this->searchParamsFetcher->fetch($request);
|
||||
}
|
||||
}
|
||||
85
application/Espo/Tools/Stream/Api/GetStreamAttachments.php
Normal file
85
application/Espo/Tools/Stream/Api/GetStreamAttachments.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Record\EntityProvider;
|
||||
use Espo\Core\Record\SearchParamsFetcher;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Tools\Stream\RecordService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class GetStreamAttachments implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityProvider $entityProvider,
|
||||
private Acl $acl,
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private RecordService $service,
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$entity = $this->getEntity($request);
|
||||
$searchParams = $this->searchParamsFetcher->fetch($request);
|
||||
|
||||
$result = $this->service->findAttachments($entity, $searchParams);
|
||||
|
||||
return ResponseComposer::json($result->toApiOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
* @throws BadRequest
|
||||
*/
|
||||
private function getEntity(Request $request): Entity
|
||||
{
|
||||
$entityType = $request->getRouteParam('entityType') ?? throw new BadRequest();
|
||||
$id = $request->getRouteParam('id') ?? throw new BadRequest();
|
||||
|
||||
$entity = $this->entityProvider->get($entityType, $id);
|
||||
|
||||
if (!$this->acl->checkEntityStream($entity)) {
|
||||
throw new Forbidden("No 'stream' access.");
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
}
|
||||
84
application/Espo/Tools/Stream/Api/PostFollowers.php
Normal file
84
application/Espo/Tools/Stream/Api/PostFollowers.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Tools\Stream\FollowerRecordService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class PostFollowers implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private FollowerRecordService $service,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$entityType = $request->getRouteParam('entityType');
|
||||
$id = $request->getRouteParam('id');
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
if (!$entityType || !$id) {
|
||||
throw new BadRequest("No entityType or id.");
|
||||
}
|
||||
|
||||
if (!$this->acl->check($entityType)) {
|
||||
throw new Forbidden("No access to $entityType.");
|
||||
}
|
||||
|
||||
$ids = $data->ids ?? (isset($data->id) ? [$data->id] : []);
|
||||
|
||||
if ($ids === [] || !is_array($ids)) {
|
||||
throw new BadRequest("No ids.");
|
||||
}
|
||||
|
||||
foreach ($ids as $userId) {
|
||||
if (!is_string($userId)) {
|
||||
throw new BadRequest("Bad id item.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($ids as $userId) {
|
||||
$this->service->link($entityType, $id, $userId);
|
||||
}
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
}
|
||||
62
application/Espo/Tools/Stream/Api/PostMyReactions.php
Normal file
62
application/Espo/Tools/Stream/Api/PostMyReactions.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Record\EntityProvider;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Tools\Stream\MyReactionsService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class PostMyReactions implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityProvider $entityProvider,
|
||||
private MyReactionsService $myReactionsService,
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id') ?? throw new BadRequest();
|
||||
$type = $request->getRouteParam('type') ?? throw new BadRequest();
|
||||
|
||||
$note = $this->entityProvider->getByClass(Note::class, $id);
|
||||
|
||||
$this->myReactionsService->react($note, $type);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
}
|
||||
164
application/Espo/Tools/Stream/Api/PostNotePin.php
Normal file
164
application/Espo/Tools/Stream/Api/PostNotePin.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error\Body;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class PostNotePin implements Action
|
||||
{
|
||||
private const PINNED_MAX_COUNT = 5;
|
||||
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private EntityManager $entityManager,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id');
|
||||
|
||||
if (!$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$note = $this->getNote($id);
|
||||
|
||||
$this->checkCanBePinned($note);
|
||||
$this->checkParent($note);
|
||||
$this->checkPinnedCount($note);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getNote(string $id): Note
|
||||
{
|
||||
$note = $this->entityManager->getRDBRepositoryByClass(Note::class)->getById($id);
|
||||
|
||||
if (!$note) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityRead($note)) {
|
||||
throw new Forbidden("No read access.");
|
||||
}
|
||||
|
||||
$note->setIsPinned(true);
|
||||
$this->entityManager->saveEntity($note);
|
||||
|
||||
return $note;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkPinnedCount(Note $entity): void
|
||||
{
|
||||
$maxCount = $this->config->get('notePinnedMaxCount') ?? self::PINNED_MAX_COUNT;
|
||||
|
||||
$count = $this->entityManager
|
||||
->getRDBRepositoryByClass(Note::class)
|
||||
->where([
|
||||
'parentId' => $entity->getParentId(),
|
||||
'parentType' => $entity->getParentType(),
|
||||
'isPinned' => true,
|
||||
])
|
||||
->count();
|
||||
|
||||
if ($count < $maxCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw Forbidden::createWithBody(
|
||||
'Pinned notes max count exceeded.',
|
||||
Body::create()->withMessageTranslation('pinnedMaxCountExceeded', 'Note', ['count' => (string) $count])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkParent(Note $note): void
|
||||
{
|
||||
if (!$note->getParentType() || !$note->getParentId()) {
|
||||
throw new Forbidden("No parent.");
|
||||
}
|
||||
|
||||
$parent = $this->entityManager->getEntityById($note->getParentType(), $note->getParentId());
|
||||
|
||||
if (!$parent) {
|
||||
throw new Forbidden("Parent not found.");
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($parent)) {
|
||||
throw new Forbidden("No parent edit access.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkCanBePinned(Note $note): void
|
||||
{
|
||||
if (!$this->isEditableType($note)) {
|
||||
throw new Forbidden("Cannot pin note.");
|
||||
}
|
||||
}
|
||||
|
||||
private function isEditableType(Note $entity): bool
|
||||
{
|
||||
return in_array($entity->getType(), [
|
||||
Note::TYPE_POST,
|
||||
Note::TYPE_EMAIL_RECEIVED,
|
||||
Note::TYPE_EMAIL_SENT,
|
||||
]);
|
||||
}
|
||||
}
|
||||
231
application/Espo/Tools/Stream/FollowerRecordService.php
Normal file
231
application/Espo/Tools/Stream/FollowerRecordService.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Error\Body as ErrorBody;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Record\Collection;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Core\Acl;
|
||||
|
||||
class FollowerRecordService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private User $user,
|
||||
private Acl $acl,
|
||||
private Metadata $metadata,
|
||||
private Service $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find followers.
|
||||
*
|
||||
* @return Collection<User>
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function find(string $entityType, string $id, SearchParams $params): Collection
|
||||
{
|
||||
$this->checkReadAccess($entityType);
|
||||
|
||||
$entity = $this->getEntity($entityType, $id);
|
||||
|
||||
return $this->service->findEntityFollowers($entity, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user to followers.
|
||||
*
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function link(string $entityType, string $id, string $userId): void
|
||||
{
|
||||
$this->checkEditAccess($entityType);
|
||||
|
||||
$entity = $this->getEntityForEdit($entityType, $id);
|
||||
$user = $this->getUser($userId);
|
||||
|
||||
$this->follow($entity, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user from followers.
|
||||
*
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function unlink(string $entityType, string $id, string $userId): void
|
||||
{
|
||||
$this->checkEditAccess($entityType);
|
||||
|
||||
$entity = $this->getEntityForEdit($entityType, $id);
|
||||
$user = $this->getUser($userId);
|
||||
|
||||
$this->service->unfollowEntity($entity, $user->getId());
|
||||
}
|
||||
|
||||
private function hasStream(string $entityType): bool
|
||||
{
|
||||
return (bool) $this->metadata->get(['scopes', $entityType, 'stream']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function checkReadAccess(string $entityType): void
|
||||
{
|
||||
if (!$this->acl->check($entityType, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden("No 'read'' access to $entityType scope.");
|
||||
}
|
||||
|
||||
if (!$this->hasStream($entityType)) {
|
||||
throw new NotFound("No stream.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function checkEditAccess(string $entityType): void
|
||||
{
|
||||
if (!$this->acl->check($entityType, Acl\Table::ACTION_EDIT)) {
|
||||
throw new Forbidden("No 'edit' access to $entityType scope.");
|
||||
}
|
||||
|
||||
if (!$this->hasStream($entityType)) {
|
||||
throw new NotFound("No stream.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function follow(Entity $entity, User $user): void
|
||||
{
|
||||
$result = $this->service->followEntity($entity, $user->getId());
|
||||
|
||||
if ($result) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw Forbidden::createWithBody(
|
||||
"Could not add user to followers.",
|
||||
ErrorBody::create()->withMessageTranslation(
|
||||
'couldNotAddFollowerUserHasNoAccessToStream',
|
||||
'Stream',
|
||||
['userName' => $user->getUserName() ?? '']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function getEntity(string $entityType, string $id): Entity
|
||||
{
|
||||
$entity = $this->entityManager->getEntityById($entityType, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound("Record not found.");
|
||||
}
|
||||
|
||||
if (!$this->acl->check($entity, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden("No 'read' access.");
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function getEntityForEdit(string $entityType, string $id): Entity
|
||||
{
|
||||
$entity = $this->entityManager->getEntityById($entityType, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound("Record not found.");
|
||||
}
|
||||
|
||||
if (!$this->acl->check($entity, Acl\Table::ACTION_EDIT)) {
|
||||
throw new Forbidden("No 'edit' access.");
|
||||
}
|
||||
|
||||
if (!$this->acl->check($entity, Acl\Table::ACTION_STREAM)) {
|
||||
throw new Forbidden("No 'stream' access.");
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getUser(string $userId): User
|
||||
{
|
||||
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
|
||||
|
||||
if (!$user) {
|
||||
throw new NotFound("User $userId not found.");
|
||||
}
|
||||
|
||||
if (!$user->isPortal() && !$this->acl->check($user, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden("No 'read' access to user $userId.");
|
||||
}
|
||||
|
||||
if ($user->isPortal() && $this->acl->getPermissionLevel(Acl\Permission::PORTAL) !== Acl\Table::LEVEL_YES) {
|
||||
throw new Forbidden("No 'portal' permission.");
|
||||
}
|
||||
|
||||
if (
|
||||
!$user->isPortal() &&
|
||||
$this->user->getId() !== $user->getId() &&
|
||||
!$this->acl->checkUserPermission($user, Acl\Permission::FOLLOWER_MANAGEMENT)
|
||||
) {
|
||||
throw new Forbidden("No 'followerManagement' permission.");
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
401
application/Espo/Tools/Stream/GlobalRecordService.php
Normal file
401
application/Espo/Tools/Stream/GlobalRecordService.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Record\Collection as RecordCollection;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use Espo\ORM\Query\Select;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
use Espo\ORM\SthCollection;
|
||||
use Espo\Tools\Stream\RecordService\NoteHelper;
|
||||
use Espo\Tools\Stream\RecordService\QueryHelper;
|
||||
|
||||
class GlobalRecordService
|
||||
{
|
||||
public const SCOPE_NAME = 'GlobalStream';
|
||||
private const ITERATION_LIMIT = 50;
|
||||
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private User $user,
|
||||
private Metadata $metadata,
|
||||
private EntityManager $entityManager,
|
||||
private QueryHelper $queryHelper,
|
||||
private NoteAccessControl $noteAccessControl,
|
||||
private NoteHelper $noteHelper,
|
||||
private MassNotePreparator $massNotePreparator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return RecordCollection<Note>
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function find(SearchParams $searchParams): RecordCollection
|
||||
{
|
||||
$this->preCheck($searchParams);
|
||||
|
||||
$maxSize = $searchParams->getMaxSize() ?? 0;
|
||||
$entityTypeList = $this->getEntityTypeList();
|
||||
|
||||
$baseBuilder = $this->queryHelper->buildBaseQueryBuilder($searchParams)
|
||||
->select($this->queryHelper->getUserQuerySelect())
|
||||
->order('number', Order::DESC)
|
||||
->limit(0, $maxSize + 1);
|
||||
|
||||
/** @var array{string, string}[] $ignoreList */
|
||||
$ignoreList = [];
|
||||
/** @var array{string, string}[] $allowList */
|
||||
$allowList = [];
|
||||
|
||||
$list = [];
|
||||
$i = 0;
|
||||
$iterationBuilder = (clone $baseBuilder);
|
||||
|
||||
while (true) {
|
||||
$queryList = [];
|
||||
|
||||
$this->buildBelongToParentQuery($iterationBuilder, $queryList, $entityTypeList, $ignoreList);
|
||||
$this->buildPostedToUserQuery($iterationBuilder, $queryList);
|
||||
$this->buildPostedToPortalQuery($iterationBuilder, $queryList);
|
||||
$this->buildPostedToTeamsQuery($iterationBuilder, $queryList);
|
||||
$this->buildPostedByUserQuery($iterationBuilder, $queryList);
|
||||
$this->buildPostedToGlobalQuery($iterationBuilder, $queryList);
|
||||
|
||||
$collection = $this->fetchCollection($queryList, $maxSize);
|
||||
|
||||
/** @var Note[] $subList */
|
||||
$subList = iterator_to_array($collection);
|
||||
|
||||
if ($subList === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Should be obtained before filtering.
|
||||
$lastNumber = end($subList)->getNumber();
|
||||
|
||||
$list = array_merge(
|
||||
$list,
|
||||
$this->filter($subList, $ignoreList, $allowList),
|
||||
);
|
||||
|
||||
if (count($list) >= $maxSize + 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
$i ++;
|
||||
|
||||
// @todo Introduce a config parameter 'globalStreamIterationLimits'.
|
||||
if ($i === self::ITERATION_LIMIT) {
|
||||
break;
|
||||
}
|
||||
|
||||
$iterationBuilder = (clone $baseBuilder)->where(['number<' => $lastNumber]);
|
||||
}
|
||||
|
||||
$list = array_slice($list, 0, $maxSize + 1);
|
||||
|
||||
/** @var Collection<Note> $collection */
|
||||
$collection = $this->entityManager->getCollectionFactory()->create(null, $list);
|
||||
|
||||
foreach ($collection as $note) {
|
||||
$note->loadAdditionalFields();
|
||||
$this->noteAccessControl->apply($note, $this->user);
|
||||
$this->noteHelper->prepare($note);
|
||||
}
|
||||
|
||||
$this->massNotePreparator->prepare($collection);
|
||||
|
||||
return RecordCollection::createNoCount($collection, $maxSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Note[] $noteList
|
||||
* @param array{string, string}[] $ignoreList
|
||||
* @param array{string, string}[] $allowList
|
||||
* @return Note[]
|
||||
*/
|
||||
private function filter(array $noteList, array &$ignoreList, array &$allowList): array
|
||||
{
|
||||
/** @var Note[] $outputList */
|
||||
$outputList = [];
|
||||
|
||||
foreach ($noteList as $note) {
|
||||
if ($this->checkAgainstList($note, $ignoreList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!$this->checkAgainstList($note, $allowList) &&
|
||||
!$this->checkAccess($note)
|
||||
) {
|
||||
$this->addToList($note, $ignoreList);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($note->getParentType() && $note->getParentId()) {
|
||||
$this->addToList($note, $allowList);
|
||||
}
|
||||
|
||||
$outputList[] = $note;
|
||||
}
|
||||
|
||||
return $outputList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{string, string}[] $list
|
||||
*/
|
||||
private function addToList(Note $note, array &$list): void
|
||||
{
|
||||
if (!$note->getParentType() || !$note->getParentId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$list[] = [$note->getParentType(), $note->getParentId()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{string, string}[] $list
|
||||
*/
|
||||
private function checkAgainstList(Note $note, array $list): bool
|
||||
{
|
||||
if (!$note->getParentType() || !$note->getParentId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return
|
||||
array_filter($list, function ($it) use ($note) {
|
||||
return
|
||||
$it[0] === $note->getParentType() &&
|
||||
$it[1] === $note->getParentId();
|
||||
}) !== [];
|
||||
}
|
||||
|
||||
private function checkAccess(Note $note): bool
|
||||
{
|
||||
$parentType = $note->getParentType();
|
||||
$parentId = $note->getParentId();
|
||||
|
||||
if (!$note->getParentType()) {
|
||||
// Only proper records are fetched.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$parentType || !$parentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkScope($parentType, Acl\Table::ACTION_STREAM)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parent = $this->entityManager->getEntityById($parentType, $parentId);
|
||||
|
||||
if (!$parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->acl->checkEntityStream($parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getEntityTypeList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
/** @var array<string, array<string, mixed>> $scopes */
|
||||
$scopes = $this->metadata->get('scopes');
|
||||
|
||||
foreach ($scopes as $scope => $item) {
|
||||
if (
|
||||
!($item['entity'] ?? false) ||
|
||||
!($item['stream'] ?? false)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!$this->acl->checkScope($scope, Acl\Table::ACTION_READ) ||
|
||||
!$this->acl->checkScope($scope, Acl\Table::ACTION_STREAM)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$list[] = $scope;
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function preCheck(SearchParams $searchParams): void
|
||||
{
|
||||
if (!$this->acl->checkScope(self::SCOPE_NAME)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if ($searchParams->getOffset()) {
|
||||
throw new BadRequest("Offset is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
* @param int $maxSize
|
||||
* @return SthCollection<Note>
|
||||
*/
|
||||
private function fetchCollection(array $queryList, int $maxSize): SthCollection
|
||||
{
|
||||
$unionBuilder = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->union()
|
||||
->all()
|
||||
->order('number', Order::DESC)
|
||||
->limit(0, $maxSize + 1);
|
||||
|
||||
foreach ($queryList as $query) {
|
||||
$unionBuilder->query($query);
|
||||
}
|
||||
|
||||
$unionQuery = $unionBuilder->build();
|
||||
|
||||
$sql = $this->entityManager
|
||||
->getQueryComposer()
|
||||
->compose($unionQuery);
|
||||
|
||||
/** @var SthCollection<Note> */
|
||||
return $this->entityManager
|
||||
->getRDBRepositoryByClass(Note::class)
|
||||
->findBySql($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildPostedToUserQuery(SelectBuilder $baseBuilder, array &$queryList): void
|
||||
{
|
||||
$queryList[] = $this->queryHelper->buildPostedToUserQuery($this->user, $baseBuilder);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildPostedToPortalQuery(SelectBuilder $baseBuilder, array &$queryList): void
|
||||
{
|
||||
$query = $this->queryHelper->buildPostedToPortalQuery($this->user, $baseBuilder);
|
||||
|
||||
if (!$query) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryList[] = $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildPostedToTeamsQuery(SelectBuilder $baseBuilder, array &$queryList): void
|
||||
{
|
||||
$query = $this->queryHelper->buildPostedToTeamsQuery($this->user, $baseBuilder);
|
||||
|
||||
if (!$query) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryList[] = $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildPostedByUserQuery(SelectBuilder $baseBuilder, array &$queryList): void
|
||||
{
|
||||
$queryList[] = $this->queryHelper->buildPostedByUserQuery($this->user, $baseBuilder);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildPostedToGlobalQuery(SelectBuilder $baseBuilder, array &$queryList): void
|
||||
{
|
||||
$query = $this->queryHelper->buildPostedToGlobalQuery($this->user, $baseBuilder);
|
||||
|
||||
if (!$query) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryList[] = $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
* @param string[] $entityTypeList
|
||||
* @param array{string, string}[] $ignoreList
|
||||
*/
|
||||
private function buildBelongToParentQuery(
|
||||
SelectBuilder $builder,
|
||||
array &$queryList,
|
||||
array $entityTypeList,
|
||||
array $ignoreList
|
||||
): void {
|
||||
|
||||
$ignoreWhere = [];
|
||||
|
||||
foreach ($ignoreList as $it) {
|
||||
$ignoreWhere[] = [
|
||||
'OR' => [
|
||||
'parentType!=' => $it[0],
|
||||
'parentId!=' => $it[1]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$queryList[] = (clone $builder)
|
||||
->where(['parentType' => $entityTypeList])
|
||||
->where($ignoreWhere)
|
||||
->build();
|
||||
}
|
||||
}
|
||||
683
application/Espo/Tools/Stream/HookProcessor.php
Normal file
683
application/Espo/Tools/Stream/HookProcessor.php
Normal file
@@ -0,0 +1,683 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Field\DateTime;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\Repository\Option\SaveContext;
|
||||
use Espo\Core\ORM\Repository\Option\SaveOption;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Core\Job\QueueName;
|
||||
use Espo\Core\Job\JobSchedulerFactory;
|
||||
|
||||
use Espo\Entities\Autofollow;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\Preferences;
|
||||
|
||||
use Espo\ORM\Defs\Params\RelationParam;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\Defs\RelationDefs;
|
||||
|
||||
use Espo\ORM\Repository\Option\RemoveOptions;
|
||||
use Espo\ORM\Repository\Option\SaveOptions;
|
||||
use Espo\Tools\Stream\Service as Service;
|
||||
use Espo\Tools\Stream\Jobs\AutoFollow as AutoFollowJob;
|
||||
use Espo\Tools\Stream\Jobs\ControlFollowers as ControlFollowersJob;
|
||||
|
||||
/**
|
||||
* Handles operations with entities.
|
||||
*/
|
||||
class HookProcessor
|
||||
{
|
||||
/** @var array<string, bool> */
|
||||
private $hasStreamCache = [];
|
||||
/** @var array<string, bool> */
|
||||
private $isLinkObservableInStreamCache = [];
|
||||
|
||||
private const FIELD_ASSIGNED_USERS = Field::ASSIGNED_USERS;
|
||||
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
private EntityManager $entityManager,
|
||||
private Service $service,
|
||||
private User $user,
|
||||
private Preferences $preferences,
|
||||
private JobSchedulerFactory $jobSchedulerFactory
|
||||
) {}
|
||||
|
||||
public function beforeSave(Entity $entity, SaveOptions $options): void
|
||||
{
|
||||
if (
|
||||
!$this->checkHasStream($entity->getEntityType()) ||
|
||||
$options->get(SaveOption::NO_STREAM) ||
|
||||
$options->get(SaveOption::SILENT)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->processStreamUpdatedAt($entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function afterSave(Entity $entity, array $options): void
|
||||
{
|
||||
$hasStream = $this->checkHasStream($entity->getEntityType());
|
||||
|
||||
if ($hasStream) {
|
||||
$this->afterSaveStream($entity, $options);
|
||||
}
|
||||
|
||||
if (!$hasStream) {
|
||||
$this->afterSaveNoStream($entity, $options);
|
||||
}
|
||||
|
||||
if (
|
||||
$entity->isNew() &&
|
||||
empty($options[SaveOption::NO_STREAM]) &&
|
||||
empty($options[SaveOption::SILENT]) &&
|
||||
$this->metadata->get(['scopes', $entity->getEntityType(), 'object'])
|
||||
) {
|
||||
$this->handleCreateRelated($entity, $options);
|
||||
}
|
||||
}
|
||||
|
||||
/** @noinspection PhpUnusedParameterInspection */
|
||||
public function afterRemove(Entity $entity, RemoveOptions $options): void
|
||||
{
|
||||
if ($this->checkHasStream($entity->getEntityType())) {
|
||||
$this->service->unfollowAllUsersFromEntity($entity);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkHasStream(string $entityType): bool
|
||||
{
|
||||
if (!array_key_exists($entityType, $this->hasStreamCache)) {
|
||||
$this->hasStreamCache[$entityType] = (bool) $this->metadata->get(['scopes', $entityType, 'stream']);
|
||||
}
|
||||
|
||||
return $this->hasStreamCache[$entityType];
|
||||
}
|
||||
|
||||
private function isLinkObservableInStream(string $entityType, string $link): bool
|
||||
{
|
||||
$key = $entityType . '__' . $link;
|
||||
|
||||
if (!array_key_exists($key, $this->isLinkObservableInStreamCache)) {
|
||||
$this->isLinkObservableInStreamCache[$key] =
|
||||
$this->metadata->get(['scopes', $entityType, 'stream']) &&
|
||||
$this->metadata->get(['entityDefs', $entityType, 'links', $link, 'audited']);
|
||||
}
|
||||
|
||||
return $this->isLinkObservableInStreamCache[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function handleCreateRelated(Entity $entity, array $options = []): void
|
||||
{
|
||||
$notifiedEntityTypeList = [];
|
||||
|
||||
$relationList = $this->entityManager
|
||||
->getDefs()
|
||||
->getEntity($entity->getEntityType())
|
||||
->getRelationList();
|
||||
|
||||
foreach ($relationList as $relation) {
|
||||
$type = $relation->getType();
|
||||
|
||||
if ($type === Entity::BELONGS_TO) {
|
||||
$this->handleCreateRelatedBelongsTo($entity, $relation, $notifiedEntityTypeList, $options);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === Entity::BELONGS_TO_PARENT) {
|
||||
$this->handleCreateRelatedBelongsToParent($entity, $relation, $notifiedEntityTypeList, $options);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === Entity::HAS_MANY || $type === Entity::MANY_MANY) {
|
||||
$this->handleCreateRelatedHasMany($entity, $relation, $notifiedEntityTypeList, $options);
|
||||
|
||||
/** @noinspection PhpUnnecessaryStopStatementInspection */
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $notifiedEntityTypeList
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function handleCreateRelatedBelongsTo(
|
||||
Entity $entity,
|
||||
RelationDefs $defs,
|
||||
array &$notifiedEntityTypeList,
|
||||
array $options,
|
||||
): void {
|
||||
|
||||
if (
|
||||
!$defs->hasForeignRelationName() ||
|
||||
!$defs->hasForeignEntityType()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$link = $defs->getName();
|
||||
$foreign = $defs->getForeignRelationName();
|
||||
$foreignEntityType = $defs->getForeignEntityType();
|
||||
|
||||
if (in_array($foreignEntityType, $notifiedEntityTypeList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $entity->get($link . 'Id');
|
||||
|
||||
if (!$id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isLinkObservableInStream($foreignEntityType, $foreign)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->service->noteCreateRelated($entity, $foreignEntityType, $id, $options);
|
||||
|
||||
$notifiedEntityTypeList[] = $foreignEntityType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $notifiedEntityTypeList
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function handleCreateRelatedBelongsToParent(
|
||||
Entity $entity,
|
||||
RelationDefs $defs,
|
||||
array &$notifiedEntityTypeList,
|
||||
array $options
|
||||
): void {
|
||||
|
||||
if (!$defs->hasForeignRelationName()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$link = $defs->getName();
|
||||
$foreign = $defs->getForeignRelationName();
|
||||
|
||||
/** @var ?string $foreignEntityType */
|
||||
$foreignEntityType = $entity->get($link . 'Type');
|
||||
|
||||
$id = $entity->get($link . 'Id');
|
||||
|
||||
if (!$foreignEntityType || !$id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($foreignEntityType, $notifiedEntityTypeList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isLinkObservableInStream($foreignEntityType, $foreign)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->service->noteCreateRelated($entity, $foreignEntityType, $id, $options);
|
||||
|
||||
$notifiedEntityTypeList[] = $foreignEntityType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $notifiedEntityTypeList
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function handleCreateRelatedHasMany(
|
||||
Entity $entity,
|
||||
RelationDefs $defs,
|
||||
array &$notifiedEntityTypeList,
|
||||
array $options
|
||||
): void {
|
||||
|
||||
if (
|
||||
!$defs->hasForeignRelationName() ||
|
||||
!$defs->hasForeignEntityType()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$link = $defs->getName();
|
||||
$foreign = $defs->getForeignRelationName();
|
||||
$foreignEntityType = $defs->getForeignEntityType();
|
||||
|
||||
if (in_array($foreignEntityType, $notifiedEntityTypeList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$entity->hasAttribute($link . 'Ids')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = $entity->get($link . 'Ids');
|
||||
|
||||
if (!is_array($ids) || !count($ids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isLinkObservableInStream($foreignEntityType, $foreign)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $ids[0];
|
||||
|
||||
$this->service->noteCreateRelated($entity, $foreignEntityType, $id, $options);
|
||||
|
||||
$notifiedEntityTypeList[] = $foreignEntityType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ignoreUserIdList
|
||||
* @return string[]
|
||||
*/
|
||||
private function getAutofollowUserIdList(string $entityType, array $ignoreUserIdList = []): array
|
||||
{
|
||||
$userIdList = [];
|
||||
|
||||
$autofollowList = $this->entityManager
|
||||
->getRDBRepository(Autofollow::ENTITY_TYPE)
|
||||
->select(['userId'])
|
||||
->where([
|
||||
'entityType' => $entityType,
|
||||
])
|
||||
->find();
|
||||
|
||||
foreach ($autofollowList as $autofollow) {
|
||||
$userId = $autofollow->get('userId');
|
||||
|
||||
if (in_array($userId, $ignoreUserIdList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$userIdList[] = $userId;
|
||||
}
|
||||
|
||||
return $userIdList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function afterSaveStream(Entity $entity, array $options): void
|
||||
{
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($entity->isNew()) {
|
||||
$this->afterSaveStreamNew($entity, $options);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->afterSaveStreamNotNew($entity, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function afterSaveStreamNew(CoreEntity $entity, array $options): void
|
||||
{
|
||||
$entityType = $entity->getEntityType();
|
||||
|
||||
$multipleField = $this->metadata->get(['streamDefs', $entityType, 'followingUsersField']) ??
|
||||
Field::ASSIGNED_USERS;
|
||||
|
||||
$hasAssignedUsersField = $entity->hasLinkMultipleField($multipleField);
|
||||
|
||||
$userIdList = [];
|
||||
|
||||
$assignedUserId = $entity->get('assignedUserId');
|
||||
$createdById = $entity->get('createdById');
|
||||
|
||||
/** @var string[] $assignedUserIdList */
|
||||
$assignedUserIdList = $hasAssignedUsersField ? $entity->getLinkMultipleIdList($multipleField) : [];
|
||||
|
||||
if (
|
||||
!$this->user->isSystem() &&
|
||||
!$this->user->isApi() &&
|
||||
$createdById &&
|
||||
$createdById === $this->user->getId() &&
|
||||
(
|
||||
$this->user->isPortal() ||
|
||||
$this->preferences->get('followCreatedEntities') ||
|
||||
in_array($entityType, $this->preferences->get('followCreatedEntityTypeList') ?? [])
|
||||
)
|
||||
) {
|
||||
$userIdList[] = $createdById;
|
||||
}
|
||||
|
||||
if ($hasAssignedUsersField) {
|
||||
$userIdList = array_unique(
|
||||
array_merge($userIdList, $assignedUserIdList)
|
||||
);
|
||||
}
|
||||
|
||||
if ($assignedUserId && !in_array($assignedUserId, $userIdList)) {
|
||||
$userIdList[] = $assignedUserId;
|
||||
}
|
||||
|
||||
if (count($userIdList)) {
|
||||
$this->service->followEntityMass($entity, $userIdList);
|
||||
}
|
||||
|
||||
if (empty($options[SaveOption::NO_STREAM]) && empty($options[SaveOption::SILENT])) {
|
||||
$this->service->noteCreate($entity, $options);
|
||||
}
|
||||
|
||||
if (in_array($this->user->getId(), $userIdList)) {
|
||||
$entity->set(Field::IS_FOLLOWED, true);
|
||||
}
|
||||
|
||||
$autofollowUserIdList = $this->getAutofollowUserIdList($entity->getEntityType(), $userIdList);
|
||||
|
||||
if (count($autofollowUserIdList)) {
|
||||
$this->jobSchedulerFactory
|
||||
->create()
|
||||
->setClassName(AutoFollowJob::class)
|
||||
->setQueue(QueueName::Q1)
|
||||
->setData([
|
||||
'userIdList' => $autofollowUserIdList,
|
||||
'entityType' => $entity->getEntityType(),
|
||||
'entityId' => $entity->getId(),
|
||||
])
|
||||
->schedule();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function afterSaveStreamNotNew(CoreEntity $entity, array $options): void
|
||||
{
|
||||
$this->afterSaveStreamNotNew1($entity, $options);
|
||||
$this->afterSaveStreamNotNew2($entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function afterSaveStreamNotNew1(CoreEntity $entity, array $options): void
|
||||
{
|
||||
if (!empty($options[SaveOption::NO_STREAM]) || !empty($options[SaveOption::SILENT])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($entity->isAttributeChanged('assignedUserId')) {
|
||||
$this->afterSaveStreamNotNewAssignedUserIdChanged($entity, $options);
|
||||
} else if (
|
||||
$entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS) &&
|
||||
$entity->isAttributeChanged(self::FIELD_ASSIGNED_USERS . 'Ids')
|
||||
) {
|
||||
$this->afterSaveStreamNotNewAssignedUsersIdsChanged($entity, $options);
|
||||
}
|
||||
|
||||
if (empty($options[SaveOption::SKIP_AUDITED])) {
|
||||
$this->service->handleAudited($entity, $options);
|
||||
}
|
||||
|
||||
$multipleField = $this->metadata->get(['streamDefs', $entity->getEntityType(), 'followingUsersField']) ??
|
||||
Field::ASSIGNED_USERS;
|
||||
|
||||
if (!$entity->hasLinkMultipleField($multipleField)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$assignedUserIdList = $entity->getLinkMultipleIdList($multipleField);
|
||||
$fetchedAssignedUserIdList = $entity->getFetched($multipleField . 'Ids') ?? [];
|
||||
|
||||
foreach ($assignedUserIdList as $userId) {
|
||||
if (in_array($userId, $fetchedAssignedUserIdList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->service->followEntity($entity, $userId);
|
||||
|
||||
if ($this->user->getId() === $userId) {
|
||||
$entity->set(Field::IS_FOLLOWED, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function afterSaveStreamNotNewAssignedUserIdChanged(Entity $entity, array $options): void
|
||||
{
|
||||
$assignedUserId = $entity->get('assignedUserId');
|
||||
|
||||
if (!$assignedUserId) {
|
||||
$this->service->noteAssign($entity, $options);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->service->followEntity($entity, $assignedUserId);
|
||||
$this->service->noteAssign($entity, $options);
|
||||
|
||||
if ($this->user->getId() === $assignedUserId) {
|
||||
$entity->set(Field::IS_FOLLOWED, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function afterSaveStreamNotNewAssignedUsersIdsChanged(CoreEntity $entity, array $options): void
|
||||
{
|
||||
$userIds = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS);
|
||||
|
||||
/** @var string[] $prevUserIds */
|
||||
$prevUserIds = $entity->getFetched(self::FIELD_ASSIGNED_USERS . 'Ids') ?? [];
|
||||
|
||||
foreach (array_diff($userIds, $prevUserIds) as $userId) {
|
||||
$this->service->followEntity($entity, $userId);
|
||||
}
|
||||
|
||||
$this->service->noteAssign($entity, $options);
|
||||
|
||||
if (in_array($this->user->getId(), $userIds)) {
|
||||
$entity->set(Field::IS_FOLLOWED, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function afterSaveStreamNotNew2(CoreEntity $entity): void
|
||||
{
|
||||
// Not recommended to use.
|
||||
$methodName = 'isChangedWithAclAffect';
|
||||
|
||||
if (
|
||||
(method_exists($entity, $methodName) && $entity->$methodName()) ||
|
||||
(
|
||||
!method_exists($entity, $methodName) &&
|
||||
(
|
||||
// @todo Introduce a metadata parameter.
|
||||
$entity->isAttributeChanged('assignedUserId') ||
|
||||
$entity->isAttributeChanged('teamsIds') ||
|
||||
$entity->isAttributeChanged('assignedUsersIds') ||
|
||||
$entity->isAttributeChanged('collaboratorsIds')
|
||||
)
|
||||
)
|
||||
) {
|
||||
$this->jobSchedulerFactory
|
||||
->create()
|
||||
->setClassName(ControlFollowersJob::class)
|
||||
->setQueue(QueueName::Q1)
|
||||
->setData([
|
||||
'entityType' => $entity->getEntityType(),
|
||||
'entityId' => $entity->getId(),
|
||||
])
|
||||
->schedule();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function afterRelate(Entity $entity, Entity $foreignEntity, string $link, array $options): void
|
||||
{
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityType = $entity->getEntityType();
|
||||
$foreignEntityType = $foreignEntity->getEntityType();
|
||||
|
||||
$foreignLink = $entity->getRelationParam($link, RelationParam::FOREIGN);
|
||||
|
||||
if (
|
||||
!empty($options[SaveOption::NO_STREAM]) ||
|
||||
!empty($options[SaveOption::SILENT]) ||
|
||||
!$this->metadata->get(['scopes', $entityType, 'object'])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$audited = $this->metadata->get(['entityDefs', $entityType, 'links', $link, 'audited']);
|
||||
$auditedForeign = $this->metadata->get(['entityDefs', $foreignEntityType, 'links', $foreignLink, 'audited']);
|
||||
|
||||
$saveContext = SaveContext::obtainFromRawOptions($options);
|
||||
|
||||
if ($audited) {
|
||||
if ($saveContext) {
|
||||
$saveContext->addDeferredAction(
|
||||
fn () => $this->service->noteRelate($foreignEntity, $entity, $options)
|
||||
);
|
||||
} else {
|
||||
$this->service->noteRelate($foreignEntity, $entity, $options);
|
||||
}
|
||||
}
|
||||
|
||||
if ($auditedForeign) {
|
||||
if ($saveContext) {
|
||||
$saveContext->addDeferredAction(
|
||||
fn () => $this->service->noteRelate($entity, $foreignEntity, $options)
|
||||
);
|
||||
} else {
|
||||
$this->service->noteRelate($entity, $foreignEntity, $options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function afterUnrelate(Entity $entity, Entity $foreignEntity, string $link, array $options): void
|
||||
{
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityType = $entity->getEntityType();
|
||||
$foreignEntityType = $foreignEntity->getEntityType();
|
||||
$foreignLink = $entity->getRelationParam($link, RelationParam::FOREIGN);
|
||||
|
||||
if (
|
||||
!empty($options[SaveOption::NO_STREAM]) ||
|
||||
!empty($options[SaveOption::SILENT]) ||
|
||||
!$this->metadata->get(['scopes', $entityType, 'object'])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$audited = $this->metadata->get(['entityDefs', $entityType, 'links', $link, 'audited']);
|
||||
$auditedForeign = $this->metadata->get(['entityDefs', $foreignEntityType, 'links', $foreignLink, 'audited']);
|
||||
|
||||
$saveContext = SaveContext::obtainFromRawOptions($options);
|
||||
|
||||
if ($audited) {
|
||||
if ($saveContext) {
|
||||
$saveContext->addDeferredAction(
|
||||
fn () => $this->service->noteUnrelate($foreignEntity, $entity, $options)
|
||||
);
|
||||
} else {
|
||||
$this->service->noteUnrelate($foreignEntity, $entity, $options);
|
||||
}
|
||||
|
||||
// @todo
|
||||
// Add time period (a few minutes). If before, remove RELATE note, don't create 'unrelate' if before.
|
||||
}
|
||||
|
||||
if ($auditedForeign) {
|
||||
if ($saveContext) {
|
||||
$saveContext->addDeferredAction(
|
||||
fn () => $this->service->noteUnrelate($entity, $foreignEntity, $options)
|
||||
);
|
||||
} else {
|
||||
$this->service->noteUnrelate($entity, $foreignEntity, $options);
|
||||
}
|
||||
|
||||
// @todo
|
||||
// Add time period (a few minutes). If before, remove RELATE note, don't create 'unrelate' if before.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function afterSaveNoStream(Entity $entity, array $options): void
|
||||
{
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!empty($options[SaveOption::NO_STREAM]) || !empty($options[SaveOption::SILENT])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$entity->isNew()) {
|
||||
if (empty($options[SaveOption::SKIP_AUDITED])) {
|
||||
$this->service->handleAudited($entity, $options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function processStreamUpdatedAt(Entity $entity): void
|
||||
{
|
||||
if (!$this->service->checkEntityNeedsUpdatedAt($entity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->set(Field::STREAM_UPDATED_AT, DateTime::createNow()->toString());
|
||||
}
|
||||
}
|
||||
125
application/Espo/Tools/Stream/Jobs/AutoFollow.php
Normal file
125
application/Espo/Tools/Stream/Jobs/AutoFollow.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Jobs;
|
||||
|
||||
use Espo\Core\Job\Job;
|
||||
use Espo\Core\Job\Job\Data;
|
||||
use Espo\Core\AclManager;
|
||||
use Espo\Core\Acl\Exceptions\NotImplemented as AclNotImplemented;
|
||||
use Espo\Core\Utils\Util;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\Notification\HookProcessor\Params;
|
||||
use Espo\Tools\Stream\Service as Service;
|
||||
use Espo\Tools\Notification\Service as NotificationService;
|
||||
use Espo\Entities\User;
|
||||
|
||||
/**
|
||||
* Handles auto-follow.
|
||||
*/
|
||||
class AutoFollow implements Job
|
||||
{
|
||||
public function __construct(
|
||||
private Service $service,
|
||||
private NotificationService $notificationService,
|
||||
private AclManager $aclManager,
|
||||
private EntityManager $entityManager,
|
||||
) {}
|
||||
|
||||
public function run(Data $data): void
|
||||
{
|
||||
/** @var string[] $userIdList */
|
||||
$userIdList = $data->get('userIdList') ?? [];
|
||||
$entityType = $data->get('entityType');
|
||||
$entityId = $data->get('entityId');
|
||||
|
||||
if (!$entityId || !$entityType) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById($entityType, $entityId);
|
||||
|
||||
if (!$entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($userIdList as $i => $userId) {
|
||||
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
|
||||
|
||||
if (!$user) {
|
||||
unset($userIdList[$i]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$hasAccess = $this->aclManager->checkEntityStream($user, $entity);
|
||||
} catch (AclNotImplemented) {
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
if (!$hasAccess) {
|
||||
unset($userIdList[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
$userIdList = array_values($userIdList);
|
||||
|
||||
foreach ($userIdList as $i => $userId) {
|
||||
if ($this->service->checkIsFollowed($entity, $userId)) {
|
||||
unset($userIdList[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
$userIdList = array_values($userIdList);
|
||||
|
||||
if (!count($userIdList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->service->followEntityMass($entity, $userIdList);
|
||||
|
||||
$notes = $this->entityManager
|
||||
->getRDBRepositoryByClass(Note::class)
|
||||
->where([
|
||||
'parentType' => $entityType,
|
||||
'parentId' => $entityId,
|
||||
])
|
||||
->order('number')
|
||||
->find();
|
||||
|
||||
// Group all notifications.
|
||||
$params = new Params(Util::generateId());
|
||||
|
||||
foreach ($notes as $note) {
|
||||
$this->notificationService->notifyAboutNote($userIdList, $note, $params);
|
||||
}
|
||||
}
|
||||
}
|
||||
105
application/Espo/Tools/Stream/Jobs/ControlFollowers.php
Normal file
105
application/Espo/Tools/Stream/Jobs/ControlFollowers.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Jobs;
|
||||
|
||||
use Espo\Core\Job\Job;
|
||||
use Espo\Core\Job\Job\Data;
|
||||
|
||||
use Espo\Core\AclManager;
|
||||
use Espo\Core\Acl\Exceptions\NotImplemented as AclNotImplemented;
|
||||
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Espo\Tools\Stream\Service as Service;
|
||||
use Espo\Entities\User;
|
||||
|
||||
/**
|
||||
* Unfollows users that don't have access.
|
||||
*/
|
||||
class ControlFollowers implements Job
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private Service $service,
|
||||
private AclManager $aclManager,
|
||||
private EntityManager $entityManager
|
||||
) {}
|
||||
|
||||
public function run(Data $data): void
|
||||
{
|
||||
$entityType = $data->get('entityType');
|
||||
$entityId = $data->get('entityId');
|
||||
|
||||
if (!$entityId || !$entityType) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById($entityType, $entityId);
|
||||
|
||||
if (!$entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$idList = $this->service->getEntityFollowerIdList($entity);
|
||||
|
||||
$userList = $this->entityManager
|
||||
->getRDBRepository(User::ENTITY_TYPE)
|
||||
->where([Attribute::ID => $idList])
|
||||
->find();
|
||||
|
||||
foreach ($userList as $user) {
|
||||
/** @var string $userId */
|
||||
$userId = $user->getId();
|
||||
|
||||
if (!$user->isActive()) {
|
||||
$this->service->unfollowEntity($entity, $userId);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($user->isPortal()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$hasAccess = $this->aclManager->checkEntityStream($user, $entity);
|
||||
} catch (AclNotImplemented) {
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
if ($hasAccess) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->service->unfollowEntity($entity, $userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
application/Espo/Tools/Stream/Jobs/ProcessNoteAcl.php
Normal file
67
application/Espo/Tools/Stream/Jobs/ProcessNoteAcl.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Jobs;
|
||||
|
||||
use Espo\Core\Job\Job;
|
||||
use Espo\Core\Job\Job\Data;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\Stream\NoteAcl\Processor;
|
||||
|
||||
class ProcessNoteAcl implements Job
|
||||
{
|
||||
public function __construct(
|
||||
private Processor $processor,
|
||||
private EntityManager $entityManager
|
||||
) {}
|
||||
|
||||
public function run(Data $data): void
|
||||
{
|
||||
$targetType = $data->getTargetType();
|
||||
$targetId = $data->getTargetId();
|
||||
$notify = $data->get('notify') === true;
|
||||
|
||||
if (!$targetType || !$targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->entityManager->hasRepository($targetType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById($targetType, $targetId);
|
||||
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->processor->process($entity, $notify);
|
||||
}
|
||||
}
|
||||
165
application/Espo/Tools/Stream/MassNotePreparator.php
Normal file
165
application/Espo/Tools/Stream/MassNotePreparator.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Utils\Config;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserReaction;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\Part\Selection;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MassNotePreparator
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private User $user,
|
||||
private Config $config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param iterable<Note> $notes
|
||||
*/
|
||||
public function prepare(iterable $notes): void
|
||||
{
|
||||
if ($this->noAvailableReactions()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = $this->getPostIds($notes);
|
||||
|
||||
$this->prepareMyReactions($ids, $notes);
|
||||
$this->prepareReactionCounts($ids, $notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Note> $notes
|
||||
* @return string[]
|
||||
*/
|
||||
private function getPostIds(iterable $notes): array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
foreach ($notes as $note) {
|
||||
if ($note->getType() !== Note::TYPE_POST) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ids[] = $note->getId();
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ids
|
||||
* @param iterable<Note> $notes
|
||||
*/
|
||||
private function prepareMyReactions(array $ids, iterable $notes): void
|
||||
{
|
||||
$myUserReactionCollection = $this->entityManager
|
||||
->getRDBRepositoryByClass(UserReaction::class)
|
||||
->where([
|
||||
'userId' => $this->user->getId(),
|
||||
'parentType' => Note::ENTITY_TYPE,
|
||||
'parentId' => $ids,
|
||||
])
|
||||
->find();
|
||||
|
||||
/** @var UserReaction[] $myUserReactions */
|
||||
$myUserReactions = iterator_to_array($myUserReactionCollection);
|
||||
|
||||
foreach ($notes as $note) {
|
||||
$noteMyReactions = [];
|
||||
|
||||
foreach ($myUserReactions as $reaction) {
|
||||
if ($reaction->getParent()->getId() !== $note->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$noteMyReactions[] = $reaction->getType();
|
||||
}
|
||||
|
||||
$note->set('myReactions', $noteMyReactions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ids
|
||||
* @param iterable<Note> $notes
|
||||
*/
|
||||
private function prepareReactionCounts(array $ids, iterable $notes): void
|
||||
{
|
||||
$query = SelectBuilder::create()
|
||||
->from(UserReaction::ENTITY_TYPE)
|
||||
->select([
|
||||
Selection::create(Expression::count(Expression::column('id')), 'count'),
|
||||
'parentId',
|
||||
'type',
|
||||
])
|
||||
->where([
|
||||
'parentType' => Note::ENTITY_TYPE,
|
||||
'parentId' => $ids,
|
||||
])
|
||||
->group('parentId')
|
||||
->group('type')
|
||||
->build();
|
||||
|
||||
/** @var array<int, array{count: int, type: string, parentId: string}> $rows */
|
||||
$rows = $this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($query)
|
||||
->fetchAll();
|
||||
|
||||
foreach ($notes as $note) {
|
||||
$counts = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($row['parentId'] !== $note->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts[$row['type']] = $row['count'];
|
||||
}
|
||||
|
||||
$note->set('reactionCounts', $counts);
|
||||
}
|
||||
}
|
||||
|
||||
private function noAvailableReactions(): bool
|
||||
{
|
||||
return $this->config->get('availableReactions', []) === [];
|
||||
}
|
||||
}
|
||||
155
application/Espo/Tools/Stream/MyReactionsService.php
Normal file
155
application/Espo/Tools/Stream/MyReactionsService.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Forbidden;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\WebSocket\Submission as WebSocketSubmission;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserReaction;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\DeleteBuilder;
|
||||
use Espo\Tools\UserReaction\NotificationService;
|
||||
|
||||
class MyReactionsService
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private EntityManager $entityManager,
|
||||
private User $user,
|
||||
private WebSocketSubmission $webSocketSubmission,
|
||||
private NotificationService $notificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function react(Note $note, string $type): void
|
||||
{
|
||||
if (!$this->isReactionAllowed($type)) {
|
||||
throw new Forbidden("Not allowed reaction '$type'.");
|
||||
}
|
||||
|
||||
if ($note->getType() !== Note::TYPE_POST) {
|
||||
throw new Forbidden("Cannot react on non-post note.");
|
||||
}
|
||||
|
||||
$this->entityManager->getTransactionManager()->run(function () use ($type, $note) {
|
||||
$repository = $this->entityManager->getRDBRepositoryByClass(UserReaction::class);
|
||||
|
||||
$found = $repository
|
||||
->forUpdate()
|
||||
->where([
|
||||
'userId' => $this->user->getId(),
|
||||
'parentType' => Note::ENTITY_TYPE,
|
||||
'parentId' => $note->getId(),
|
||||
'type' => $type,
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if ($found) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteAll($note);
|
||||
$this->notificationService->removeNoteUnread($note, $this->user);
|
||||
|
||||
$reaction = $repository->getNew();
|
||||
|
||||
$reaction
|
||||
->setParent($note)
|
||||
->setUser($this->user)
|
||||
->setType($type);
|
||||
|
||||
$this->entityManager->saveEntity($reaction);
|
||||
});
|
||||
|
||||
$this->webSocketSubmit($note);
|
||||
$this->notificationService->notifyNote($note, $type);
|
||||
}
|
||||
|
||||
public function unReact(Note $note, string $type): void
|
||||
{
|
||||
$repository = $this->entityManager->getRDBRepositoryByClass(UserReaction::class);
|
||||
|
||||
$reaction = $repository
|
||||
->where([
|
||||
'userId' => $this->user->getId(),
|
||||
'parentType' => $note->getEntityType(),
|
||||
'parentId' => $note->getId(),
|
||||
'type' => $type,
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if (!$reaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notificationService->removeNoteUnread($note, $this->user, $type);
|
||||
|
||||
$this->entityManager->removeEntity($reaction);
|
||||
|
||||
$this->webSocketSubmit($note);
|
||||
}
|
||||
|
||||
private function isReactionAllowed(string $type): bool
|
||||
{
|
||||
/** @var string[] $allowedReactions */
|
||||
$allowedReactions = $this->config->get('availableReactions') ?? [];
|
||||
|
||||
return in_array($type, $allowedReactions);
|
||||
}
|
||||
|
||||
private function deleteAll(Note $note): void
|
||||
{
|
||||
$deleteQuery = DeleteBuilder::create()
|
||||
->from(UserReaction::ENTITY_TYPE)
|
||||
->where([
|
||||
'userId' => $this->user->getId(),
|
||||
'parentType' => Note::ENTITY_TYPE,
|
||||
'parentId' => $note->getId(),
|
||||
])
|
||||
->build();
|
||||
|
||||
$this->entityManager->getQueryExecutor()->execute($deleteQuery);
|
||||
}
|
||||
|
||||
private function webSocketSubmit(Note $note): void
|
||||
{
|
||||
$topic = "streamUpdate.{$note->getParentType()}.{$note->getParentId()}";
|
||||
|
||||
$this->webSocketSubmission->submit($topic, null, ['noteId' => $note->getId()]);
|
||||
|
||||
$topicUpdate = "recordUpdate.Note.{$note->getId()}";
|
||||
|
||||
$this->webSocketSubmission->submit($topicUpdate);
|
||||
}
|
||||
}
|
||||
99
application/Espo/Tools/Stream/NoteAccessControl.php
Normal file
99
application/Espo/Tools/Stream/NoteAccessControl.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Utils\Metadata;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Core\Utils\Acl\UserAclManagerProvider;
|
||||
|
||||
class NoteAccessControl
|
||||
{
|
||||
public function __construct(
|
||||
private UserAclManagerProvider $userAclManagerProvider,
|
||||
private Metadata $metadata,
|
||||
) {}
|
||||
|
||||
public function apply(Note $note, User $user): void
|
||||
{
|
||||
if ($note->getType() === Note::TYPE_UPDATE && $note->getParentType()) {
|
||||
$data = $note->getData();
|
||||
|
||||
$fields = $data->fields ?? [];
|
||||
|
||||
$data->attributes = $data->attributes ?? (object) [];
|
||||
$data->attributes->was = $data->attributes->was ?? (object) [];
|
||||
$data->attributes->became = $data->attributes->became ?? (object) [];
|
||||
|
||||
$forbiddenFieldList = $this->userAclManagerProvider
|
||||
->get($user)
|
||||
->getScopeForbiddenFieldList($user, $note->getParentType());
|
||||
|
||||
$aclManager = $this->userAclManagerProvider->get($user);
|
||||
|
||||
$forbiddenAttributeList = $aclManager->getScopeForbiddenAttributeList($user, $note->getParentType());
|
||||
|
||||
$data->fields = array_values(array_diff($fields, $forbiddenFieldList));
|
||||
|
||||
foreach ($forbiddenAttributeList as $attribute) {
|
||||
unset($data->attributes->was->$attribute);
|
||||
unset($data->attributes->became->$attribute);
|
||||
}
|
||||
|
||||
$statusField = $this->metadata->get("scopes.{$note->getParentType()}.statusField");
|
||||
|
||||
if (
|
||||
$statusField &&
|
||||
!$aclManager->checkField($user, $note->getParentType(), $statusField)
|
||||
) {
|
||||
unset($data->value);
|
||||
}
|
||||
|
||||
$note->setData($data);
|
||||
}
|
||||
|
||||
if ($note->getType() === Note::TYPE_CREATE && $note->getParentType()) {
|
||||
$forbiddenFieldList = $this->userAclManagerProvider
|
||||
->get($user)
|
||||
->getScopeForbiddenFieldList($user, $note->getParentType());
|
||||
|
||||
$data = $note->getData();
|
||||
|
||||
$field = $data->statusField ?? null;
|
||||
|
||||
if (in_array($field, $forbiddenFieldList)) {
|
||||
$data->statusValue = null;
|
||||
$data->statusStyle = null; // Legacy.
|
||||
}
|
||||
|
||||
$note->setData($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
99
application/Espo/Tools/Stream/NoteAcl/AccessModifier.php
Normal file
99
application/Espo/Tools/Stream/NoteAcl/AccessModifier.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\NoteAcl;
|
||||
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Entity;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @internal
|
||||
* @todo Job to process the rest, after the last ID.
|
||||
*/
|
||||
class AccessModifier
|
||||
{
|
||||
/** @var string[] */
|
||||
private array $ignoreEntityTypeList = [
|
||||
'Note',
|
||||
'User',
|
||||
'Team',
|
||||
'Role',
|
||||
'Portal',
|
||||
'PortalRole',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
private Processor $processor
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function process(Entity $entity): void
|
||||
{
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->toProcess($entity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->processor->process($entity);
|
||||
}
|
||||
|
||||
private function toProcess(CoreEntity $entity): bool
|
||||
{
|
||||
$entityType = $entity->getEntityType();
|
||||
|
||||
if (in_array($entityType, $this->ignoreEntityTypeList)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->metadata->get(['scopes', $entityType, 'acl'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->metadata->get(['scopes', $entityType, 'object'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
240
application/Espo/Tools/Stream/NoteAcl/Processor.php
Normal file
240
application/Espo/Tools/Stream/NoteAcl/Processor.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\NoteAcl;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Espo\Core\AclManager;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
|
||||
use DateTimeInterface;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class Processor
|
||||
{
|
||||
/**
|
||||
* 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 NOTE_NOTIFICATION_PERIOD = '1 hour';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Config $config,
|
||||
private AclManager $aclManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param bool $notify Process notifications for notes.
|
||||
*/
|
||||
public function process(CoreEntity $entity, bool $notify = false): void
|
||||
{
|
||||
$entityType = $entity->getEntityType();
|
||||
|
||||
$usersAttributeIsChanged = false;
|
||||
$teamsAttributeIsChanged = false;
|
||||
|
||||
$ownerUserField = $this->aclManager->getReadOwnerUserField($entityType);
|
||||
|
||||
$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() === FieldType::LINK_MULTIPLE) {
|
||||
$ownerUserIdAttribute = $ownerUserField . 'Ids';
|
||||
} else if ($fieldDefs->getType() === FieldType::LINK) {
|
||||
$ownerUserIdAttribute = $ownerUserField . 'Id';
|
||||
} else {
|
||||
throw new LogicException("Bad read-owner user field type.");
|
||||
}
|
||||
|
||||
if ($entity->isAttributeChanged($ownerUserIdAttribute)) {
|
||||
$usersAttributeIsChanged = true;
|
||||
}
|
||||
|
||||
if ($usersAttributeIsChanged || $notify) {
|
||||
if ($fieldDefs->getType() === FieldType::LINK_MULTIPLE) {
|
||||
$userIdList = $entity->getLinkMultipleIdList($ownerUserField);
|
||||
} else {
|
||||
$userId = $entity->get($ownerUserIdAttribute);
|
||||
|
||||
$userIdList = $userId ? [$userId] : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($entity->hasLinkMultipleField(Field::TEAMS)) {
|
||||
if ($entity->isAttributeChanged(Field::TEAMS . 'Ids')) {
|
||||
$teamsAttributeIsChanged = true;
|
||||
}
|
||||
|
||||
if ($teamsAttributeIsChanged || $notify) {
|
||||
$teamIdList = $entity->getLinkMultipleIdList(Field::TEAMS);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$usersAttributeIsChanged && !$teamsAttributeIsChanged && !$notify) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notificationThreshold = $this->getNotificationThreshold();
|
||||
|
||||
foreach ($this->getNotes($entity) as $note) {
|
||||
$this->processNoteAclItem($entity, $note, [
|
||||
'teamsAttributeIsChanged' => $teamsAttributeIsChanged,
|
||||
'usersAttributeIsChanged' => $usersAttributeIsChanged,
|
||||
'notify' => $notify,
|
||||
'teamIdList' => $teamIdList,
|
||||
'userIdList' => $userIdList,
|
||||
'notificationThreshold' => $notificationThreshold,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* teamsAttributeIsChanged: bool,
|
||||
* usersAttributeIsChanged: bool,
|
||||
* notify: bool,
|
||||
* teamIdList: string[],
|
||||
* userIdList: string[],
|
||||
* notificationThreshold: DateTimeInterface,
|
||||
* } $params
|
||||
*/
|
||||
private function processNoteAclItem(Entity $entity, Note $note, array $params): void
|
||||
{
|
||||
$teamsAttributeIsChanged = $params['teamsAttributeIsChanged'];
|
||||
$usersAttributeIsChanged = $params['usersAttributeIsChanged'];
|
||||
$notify = $params['notify'];
|
||||
|
||||
$teamIdList = $params['teamIdList'];
|
||||
$userIdList = $params['userIdList'];
|
||||
|
||||
$notificationThreshold = $params['notificationThreshold'];
|
||||
|
||||
$createdAt = $note->getCreatedAt();
|
||||
|
||||
if (!$createdAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$entity->isNew() && $createdAt->toTimestamp() < $notificationThreshold->getTimestamp()) {
|
||||
$notify = false;
|
||||
}
|
||||
|
||||
if ($teamsAttributeIsChanged || $notify) {
|
||||
$note->setTeamsIds($teamIdList);
|
||||
}
|
||||
|
||||
if ($usersAttributeIsChanged || $notify) {
|
||||
$note->setUsersIds($userIdList);
|
||||
}
|
||||
|
||||
$this->entityManager->saveEntity($note, ['forceProcessNotifications' => $notify]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<Note>
|
||||
*/
|
||||
private function getNotes(CoreEntity $entity): Collection
|
||||
{
|
||||
$entityType = $entity->getEntityType();
|
||||
$limit = $this->config->get('noteAclLimit', self::NOTE_ACL_LIMIT);
|
||||
$aclThreshold = $this->getAclThreshold();
|
||||
|
||||
return $this->entityManager
|
||||
->getRDBRepository(Note::ENTITY_TYPE)
|
||||
->sth()
|
||||
->where([
|
||||
'OR' => [
|
||||
[
|
||||
'relatedId' => $entity->getId(),
|
||||
'relatedType' => $entityType,
|
||||
],
|
||||
[
|
||||
'parentId' => $entity->getId(),
|
||||
'parentType' => $entityType,
|
||||
'superParentId!=' => null,
|
||||
'relatedId' => null,
|
||||
],
|
||||
]
|
||||
])
|
||||
->where(['createdAt>=' => $aclThreshold->toString()])
|
||||
->select([
|
||||
'id',
|
||||
'parentType',
|
||||
'parentId',
|
||||
'superParentType',
|
||||
'superParentId',
|
||||
'isInternal',
|
||||
'relatedType',
|
||||
'relatedId',
|
||||
Field::CREATED_AT,
|
||||
])
|
||||
->order('number', Order::DESC)
|
||||
->limit(0, $limit)
|
||||
->find();
|
||||
}
|
||||
|
||||
private function getNotificationThreshold(): DateTimeInterface
|
||||
{
|
||||
$notificationPeriod = '-' . $this->config->get('noteNotificationPeriod', self::NOTE_NOTIFICATION_PERIOD);
|
||||
|
||||
return (new DateTimeImmutable())->modify($notificationPeriod);
|
||||
}
|
||||
|
||||
private function getAclThreshold(): DateTime
|
||||
{
|
||||
$aclPeriod = '-' . $this->config->get('noteAclPeriod', self::NOTE_ACL_PERIOD);
|
||||
|
||||
return DateTime::createNow()->modify($aclPeriod);
|
||||
}
|
||||
}
|
||||
61
application/Espo/Tools/Stream/NoteUtil.php
Normal file
61
application/Espo/Tools/Stream/NoteUtil.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Utils\Config\ApplicationConfig;
|
||||
use Espo\Entities\Note;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class NoteUtil
|
||||
{
|
||||
public function __construct(private ApplicationConfig $applicationConfig) {}
|
||||
|
||||
public function handlePostText(Note $entity): void
|
||||
{
|
||||
$post = $entity->getPost();
|
||||
|
||||
if (!$post) {
|
||||
return;
|
||||
}
|
||||
|
||||
$siteUrl = $this->applicationConfig->getSiteUrl();
|
||||
|
||||
// PhpStorm inspection highlights RegExpRedundantEscape by a mistake.
|
||||
/** @noinspection RegExpRedundantEscape */
|
||||
$regexp = '/(\s|^)' . preg_quote($siteUrl, '/') .
|
||||
'(\/portal|\/portal\/[a-zA-Z0-9]*)?\/#([A-Z][a-zA-Z0-9]*)\/view\/([a-zA-Z0-9-]*)/';
|
||||
|
||||
$post = preg_replace($regexp, '\1[\3/\4](#\3/view/\4)', $post);
|
||||
|
||||
$entity->setPost($post);
|
||||
}
|
||||
}
|
||||
524
application/Espo/Tools/Stream/RecordService.php
Normal file
524
application/Espo/Tools/Stream/RecordService.php
Normal file
@@ -0,0 +1,524 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Name\Field;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Entities\Attachment;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Acl\Table;
|
||||
use Espo\Core\Record\Collection as RecordCollection;
|
||||
use Espo\ORM\Query\Part\Condition;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
use Espo\Tools\Stream\RecordService\Helper;
|
||||
use Espo\Tools\Stream\RecordService\NoteHelper;
|
||||
use Espo\Tools\Stream\RecordService\QueryHelper;
|
||||
|
||||
class RecordService
|
||||
{
|
||||
private const PINNED_MAX_SIZE = 100;
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private User $user,
|
||||
private Acl $acl,
|
||||
private NoteAccessControl $noteAccessControl,
|
||||
private Helper $helper,
|
||||
private QueryHelper $queryHelper,
|
||||
private NoteHelper $noteHelper,
|
||||
private MassNotePreparator $massNotePreparator,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find a record stream records.
|
||||
*
|
||||
* @return RecordCollection<Note>
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function find(string $scope, string $id, SearchParams $searchParams): RecordCollection
|
||||
{
|
||||
$this->checkAccess($scope, $id);
|
||||
|
||||
return $this->findInternal($scope, $id, $searchParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a record stream records.
|
||||
*
|
||||
* @return RecordCollection<Note>
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function findUpdates(string $scope, string $id, SearchParams $searchParams): RecordCollection
|
||||
{
|
||||
if ($this->user->isPortal()) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if ($this->acl->getPermissionLevel(Acl\Permission::AUDIT) !== Table::LEVEL_YES) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById($scope, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityRead($entity)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if ($entity instanceof User && !$this->user->isAdmin()) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$searchParams = $searchParams->withPrimaryFilter('updates');
|
||||
|
||||
return $this->findInternal($scope, $id, $searchParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pinned notes.
|
||||
*
|
||||
* @return Collection<Note>
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function getPinned(string $scope, string $id): Collection
|
||||
{
|
||||
$this->checkAccess($scope, $id);
|
||||
|
||||
$builder = $this->queryHelper->buildBaseQueryBuilder(SearchParams::create());
|
||||
|
||||
$where = [
|
||||
'parentType' => $scope,
|
||||
'parentId' => $id,
|
||||
'isPinned' => true,
|
||||
];
|
||||
|
||||
if ($this->user->isPortal()) {
|
||||
$where[] = ['isInternal' => true];
|
||||
}
|
||||
|
||||
$this->applyPortalAccess($builder, $where);
|
||||
$this->applyAccess($builder, $id, $scope, $where);
|
||||
$this->applyIgnore($where);
|
||||
|
||||
$builder->where($where);
|
||||
|
||||
$builder
|
||||
->limit(0, self::PINNED_MAX_SIZE)
|
||||
->order('number', 'DESC');
|
||||
|
||||
$collection = $this->entityManager
|
||||
->getRDBRepositoryByClass(Note::class)
|
||||
->clone($builder->build())
|
||||
->find();
|
||||
|
||||
foreach ($collection as $item) {
|
||||
$this->prepareNote($item, $scope, $id);
|
||||
}
|
||||
|
||||
$this->massNotePreparator->prepare($collection);
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a record stream records.
|
||||
*
|
||||
* @return RecordCollection<Note>
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
*/
|
||||
private function findInternal(string $scope, string $id, SearchParams $searchParams): RecordCollection
|
||||
{
|
||||
$builder = $this->prepareSelectBuilder($scope, $id, $searchParams);
|
||||
|
||||
$offset = $searchParams->getOffset();
|
||||
$maxSize = $searchParams->getMaxSize();
|
||||
|
||||
$countBuilder = clone $builder;
|
||||
|
||||
$builder
|
||||
->limit($offset ?? 0, $maxSize)
|
||||
->order('number', 'DESC');
|
||||
|
||||
$collection = $this->entityManager
|
||||
->getRDBRepositoryByClass(Note::class)
|
||||
->clone($builder->build())
|
||||
->find();
|
||||
|
||||
foreach ($collection as $e) {
|
||||
$this->prepareNote($e, $scope, $id);
|
||||
}
|
||||
|
||||
$this->massNotePreparator->prepare($collection);
|
||||
|
||||
$count = $this->entityManager
|
||||
->getRDBRepositoryByClass(Note::class)
|
||||
->clone($countBuilder->build())
|
||||
->count();
|
||||
|
||||
return RecordCollection::create($collection, $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $where
|
||||
*/
|
||||
private function applyAccess(
|
||||
SelectBuilder $builder,
|
||||
string $id,
|
||||
string $scope,
|
||||
array &$where
|
||||
): void {
|
||||
|
||||
if ($this->user->isPortal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$onlyTeamEntityTypeList = $this->helper->getOnlyTeamEntityTypeList($this->user);
|
||||
$onlyOwnEntityTypeList = $this->helper->getOnlyOwnEntityTypeList($this->user);
|
||||
|
||||
if (
|
||||
!count($onlyTeamEntityTypeList) &&
|
||||
!count($onlyOwnEntityTypeList)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$builder
|
||||
->distinct()
|
||||
->leftJoin(Field::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(),
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $where
|
||||
*/
|
||||
private function applyIgnore(array &$where): void
|
||||
{
|
||||
$ignoreScopeList = $this->helper->getIgnoreScopeList($this->user, true);
|
||||
$ignoreRelatedScopeList = $this->helper->getIgnoreScopeList($this->user);
|
||||
|
||||
if ($ignoreRelatedScopeList === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$where[] = [
|
||||
'OR' => [
|
||||
'relatedType' => null,
|
||||
'relatedType!=' => $ignoreRelatedScopeList,
|
||||
]
|
||||
];
|
||||
|
||||
$where[] = [
|
||||
'OR' => [
|
||||
'parentType' => null,
|
||||
'parentType!=' => $ignoreScopeList,
|
||||
]
|
||||
];
|
||||
|
||||
if (!in_array(Email::ENTITY_TYPE, $ignoreRelatedScopeList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$where[] = [
|
||||
'type!=' => [
|
||||
Note::TYPE_EMAIL_RECEIVED,
|
||||
Note::TYPE_EMAIL_SENT,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $where
|
||||
*/
|
||||
private function applyPortalAccess(SelectBuilder $builder, array &$where): void
|
||||
{
|
||||
if (!$this->user->isPortal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notAllEntityTypeList = $this->helper->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,
|
||||
];
|
||||
}
|
||||
|
||||
private function prepareNote(Note $note, string $scope, string $id): void
|
||||
{
|
||||
if (
|
||||
$note->getType() === Note::TYPE_POST ||
|
||||
$note->getType() === Note::TYPE_EMAIL_RECEIVED ||
|
||||
$note->getType() === Note::TYPE_EMAIL_SENT
|
||||
) {
|
||||
$note->loadAttachments();
|
||||
}
|
||||
|
||||
if (
|
||||
$note->getParentId() && $note->getParentType() &&
|
||||
($note->getParentId() !== $id || $note->getParentType() !== $scope)
|
||||
) {
|
||||
$note->loadParentNameField(Field::PARENT);
|
||||
}
|
||||
|
||||
if ($note->getRelatedId() && $note->getRelatedType()) {
|
||||
$note->loadParentNameField('related');
|
||||
}
|
||||
|
||||
$this->noteAccessControl->apply($note, $this->user);
|
||||
|
||||
if ($note->getType() === Note::TYPE_UPDATE) {
|
||||
$this->noteHelper->prepare($note);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function checkAccess(string $scope, string $id): void
|
||||
{
|
||||
if ($scope === User::ENTITY_TYPE) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById($scope, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound("Record not found.");
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntity($entity, Table::ACTION_STREAM)) {
|
||||
throw new Forbidden("No stream access.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return RecordCollection<Attachment>
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
* @since 9.1.0
|
||||
* @internal
|
||||
*/
|
||||
public function findAttachments(Entity $entity, SearchParams $searchParams): RecordCollection
|
||||
{
|
||||
$entityType = $entity->getEntityType();
|
||||
$id = $entity->getId();
|
||||
|
||||
$this->checkAccess($entityType, $id);
|
||||
|
||||
$noteBuilder = $this->prepareSelectBuilder($entityType, $id, SearchParams::create());
|
||||
|
||||
$noteBuilder->select(['id']);
|
||||
|
||||
$searchParams = $searchParams->withSelect([
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'size',
|
||||
'parentType',
|
||||
'parentId',
|
||||
'createdAt',
|
||||
'createdById',
|
||||
'createdByName',
|
||||
]);
|
||||
|
||||
$query = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Attachment::ENTITY_TYPE)
|
||||
->withSearchParams($searchParams)
|
||||
->buildQueryBuilder()
|
||||
->where(
|
||||
Condition::in(
|
||||
Expression::column('parentId'),
|
||||
$noteBuilder->build()
|
||||
)
|
||||
)
|
||||
->where(['parentType' => Note::ENTITY_TYPE])
|
||||
->build();
|
||||
|
||||
$collection = $this->entityManager
|
||||
->getRDBRepositoryByClass(Attachment::class)
|
||||
->clone($query)
|
||||
->find();
|
||||
|
||||
$total = $this->entityManager
|
||||
->getRDBRepositoryByClass(Attachment::class)
|
||||
->clone($query)
|
||||
->count();
|
||||
|
||||
return RecordCollection::create($collection, $total);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function prepareSelectBuilder(string $scope, string $id, SearchParams $searchParams): SelectBuilder
|
||||
{
|
||||
$builder = $this->queryHelper->buildBaseQueryBuilder($searchParams);
|
||||
|
||||
$where = $this->user->isPortal() ?
|
||||
[
|
||||
'parentType' => $scope,
|
||||
'parentId' => $id,
|
||||
'isInternal' => false,
|
||||
] :
|
||||
[
|
||||
'OR' => [
|
||||
[
|
||||
'parentType' => $scope,
|
||||
'parentId' => $id,
|
||||
],
|
||||
[
|
||||
'superParentType' => $scope,
|
||||
'superParentId' => $id,
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
$this->applyPortalAccess($builder, $where);
|
||||
$this->applyAccess($builder, $id, $scope, $where);
|
||||
$this->applyIgnore($where);
|
||||
|
||||
$builder->where($where);
|
||||
|
||||
return $builder;
|
||||
}
|
||||
}
|
||||
202
application/Espo/Tools/Stream/RecordService/Helper.php
Normal file
202
application/Espo/Tools/Stream/RecordService/Helper.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Utils\Acl\UserAclManagerProvider;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\User;
|
||||
|
||||
class Helper
|
||||
{
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
private AclManager $aclManager,
|
||||
private UserAclManagerProvider $userAclManagerProvider
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
139
application/Espo/Tools/Stream/RecordService/NoteHelper.php
Normal file
139
application/Espo/Tools/Stream/RecordService/NoteHelper.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Name\Field;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\FieldUtil;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use stdClass;
|
||||
|
||||
class NoteHelper
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private FieldUtil $fieldUtil
|
||||
) {}
|
||||
|
||||
public function prepare(Note $note): void
|
||||
{
|
||||
if ($note->getType() === Note::TYPE_UPDATE) {
|
||||
$this->prepareNoteUpdate($note);
|
||||
}
|
||||
}
|
||||
|
||||
private function prepareNoteUpdate(Note $note): void
|
||||
{
|
||||
$data = $note->getData();
|
||||
|
||||
/** @var ?string[] $fieldList */
|
||||
$fieldList = $data->fields ?? null;
|
||||
$attributes = $data->attributes ?? null;
|
||||
|
||||
if (!$attributes instanceof stdClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
$was = $attributes->was ?? null;
|
||||
|
||||
if (!$was instanceof stdClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_array($fieldList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($fieldList as $field) {
|
||||
if ($this->loadNoteUpdateWasForField($note, $field, $was)) {
|
||||
$note->setData($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function loadNoteUpdateWasForField(Note $note, string $field, stdClass $was): bool
|
||||
{
|
||||
if (!$note->getParentType() || !$note->getParentId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$type = $this->fieldUtil->getFieldType($note->getParentType(), $field);
|
||||
|
||||
if ($type === FieldType::LINK_MULTIPLE) {
|
||||
$this->loadNoteUpdateWasForFieldLinkMultiple($note, $field, $was);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function loadNoteUpdateWasForFieldLinkMultiple(Note $note, string $field, stdClass $was): void
|
||||
{
|
||||
/** @var ?string[] $ids */
|
||||
$ids = $was->{$field . 'Ids'} ?? null;
|
||||
|
||||
$names = (object) [];
|
||||
|
||||
if (!is_array($ids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityType = $note->getParentType();
|
||||
|
||||
if (!$entityType) {
|
||||
return;
|
||||
}
|
||||
|
||||
$foreignEntityType = $this->entityManager
|
||||
->getDefs()
|
||||
->getEntity($entityType)
|
||||
->tryGetRelation($field)
|
||||
?->tryGetForeignEntityType();
|
||||
|
||||
if (!$foreignEntityType) {
|
||||
return;
|
||||
}
|
||||
|
||||
$collection = $this->entityManager
|
||||
->getRDBRepository($foreignEntityType)
|
||||
->select([Attribute::ID, Field::NAME])
|
||||
->where([Attribute::ID => $ids])
|
||||
->find();
|
||||
|
||||
foreach ($collection as $entity) {
|
||||
$names->{$entity->getId()} = $entity->get(Field::NAME);
|
||||
}
|
||||
|
||||
$was->{$field . 'Names'} = $names;
|
||||
}
|
||||
}
|
||||
237
application/Espo/Tools/Stream/RecordService/QueryHelper.php
Normal file
237
application/Espo/Tools/Stream/RecordService/QueryHelper.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Permission;
|
||||
use Espo\Core\Acl\Table;
|
||||
use Espo\Core\AclManager;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Condition as Cond;
|
||||
use Espo\ORM\Query\Part\Expression as Expr;
|
||||
use Espo\ORM\Query\Select;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
|
||||
class QueryHelper
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private AclManager $aclManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function buildBaseQueryBuilder(SearchParams $searchParams): SelectBuilder
|
||||
{
|
||||
$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 getUserQuerySelect(): array
|
||||
{
|
||||
return [
|
||||
'id',
|
||||
'number',
|
||||
'type',
|
||||
'post',
|
||||
'data',
|
||||
'parentType',
|
||||
'parentId',
|
||||
'relatedType',
|
||||
'relatedId',
|
||||
'targetType',
|
||||
Field::CREATED_AT,
|
||||
'createdById',
|
||||
'createdByName',
|
||||
'isGlobal',
|
||||
'isInternal',
|
||||
'createdByGender',
|
||||
];
|
||||
}
|
||||
|
||||
public function buildPostedToUserQuery(User $user, SelectBuilder $baseBuilder): Select
|
||||
{
|
||||
return (clone $baseBuilder)
|
||||
->where([
|
||||
'type' => Note::TYPE_POST,
|
||||
'targetType' => Note::TARGET_USERS,
|
||||
'parentId' => null,
|
||||
'createdById!=' => $user->getId(),
|
||||
'isGlobal' => false,
|
||||
])
|
||||
->where(
|
||||
Cond::in(
|
||||
Expr::column('id'),
|
||||
SelectBuilder::create()
|
||||
->select('noteId')
|
||||
->from('NoteUser')
|
||||
->where(['userId' => $user->getId()])
|
||||
->build()
|
||||
)
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
public function buildPostedToPortalQuery(User $user, SelectBuilder $baseBuilder): ?Select
|
||||
{
|
||||
if (!$user->isPortal()) {
|
||||
if ($this->aclManager->getPermissionLevel($user, Permission::PORTAL) !== Table::LEVEL_YES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (clone $baseBuilder)
|
||||
->where([
|
||||
'parentId' => null,
|
||||
'type' => Note::TYPE_POST,
|
||||
'targetType' => Note::TARGET_PORTALS,
|
||||
'createdById!=' => $user->getId(),
|
||||
'isGlobal' => false,
|
||||
])
|
||||
->build();
|
||||
}
|
||||
|
||||
$portalIdList = $user->getPortals()->getIdList();
|
||||
|
||||
if ($portalIdList === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (clone $baseBuilder)
|
||||
->where([
|
||||
'parentId' => null,
|
||||
'type' => Note::TYPE_POST,
|
||||
'targetType' => Note::TARGET_PORTALS,
|
||||
'createdById!=' => $user->getId(),
|
||||
'isGlobal' => false,
|
||||
])
|
||||
->where(
|
||||
Cond::in(
|
||||
Expr::column('id'),
|
||||
SelectBuilder::create()
|
||||
->select('noteId')
|
||||
->from('NotePortal')
|
||||
->where(['portalId' => $portalIdList])
|
||||
->build()
|
||||
)
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
public function buildPostedToTeamsQuery(User $user, SelectBuilder $baseBuilder): ?Select
|
||||
{
|
||||
if ($user->getTeamIdList() === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (clone $baseBuilder)
|
||||
->where([
|
||||
'parentId' => null,
|
||||
'type' => Note::TYPE_POST,
|
||||
'targetType' => Note::TARGET_TEAMS,
|
||||
'createdById!=' => $user->getId(),
|
||||
'isGlobal' => false,
|
||||
])
|
||||
->where(
|
||||
Cond::in(
|
||||
Expr::column('id'),
|
||||
SelectBuilder::create()
|
||||
->select('noteId')
|
||||
->from('NoteTeam')
|
||||
->where(['teamId' => $user->getTeamIdList()])
|
||||
->build()
|
||||
)
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
public function buildPostedByUserQuery(User $user, SelectBuilder $baseBuilder): Select
|
||||
{
|
||||
return (clone $baseBuilder)
|
||||
->where([
|
||||
'parentId' => null,
|
||||
'type' => Note::TYPE_POST,
|
||||
'createdById' => $user->getId(),
|
||||
])
|
||||
->build();
|
||||
}
|
||||
|
||||
public function buildPostedToGlobalQuery(User $user, SelectBuilder $baseBuilder): ?Select
|
||||
{
|
||||
if ($user->isPortal() || $user->isApi()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (clone $baseBuilder)
|
||||
->where([
|
||||
'type' => Note::TYPE_POST,
|
||||
'targetType' => Note::TARGET_ALL,
|
||||
'parentId' => null,
|
||||
'createdById!=' => $user->getId(),
|
||||
'isGlobal' => true,
|
||||
])
|
||||
->build();
|
||||
}
|
||||
}
|
||||
1331
application/Espo/Tools/Stream/Service.php
Normal file
1331
application/Espo/Tools/Stream/Service.php
Normal file
File diff suppressed because it is too large
Load Diff
623
application/Espo/Tools/Stream/UserRecordService.php
Normal file
623
application/Espo/Tools/Stream/UserRecordService.php
Normal file
@@ -0,0 +1,623 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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\Name\Field;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Modules\Crm\Entities\Account;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\StreamSubscription;
|
||||
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\Condition as Cond;
|
||||
use Espo\ORM\Query\Part\Expression as Expr;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use Espo\ORM\Query\Part\Where\OrGroup;
|
||||
use Espo\ORM\Query\Select;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
use Espo\Tools\Stream\RecordService\Helper;
|
||||
use Espo\Tools\Stream\RecordService\NoteHelper;
|
||||
use Espo\Tools\Stream\RecordService\QueryHelper;
|
||||
|
||||
class UserRecordService
|
||||
{
|
||||
private const FILTER_POSTS = 'posts';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private User $user,
|
||||
private Metadata $metadata,
|
||||
private Acl $acl,
|
||||
private UserAclManagerProvider $userAclManagerProvider,
|
||||
private NoteAccessControl $noteAccessControl,
|
||||
private Helper $helper,
|
||||
private QueryHelper $queryHelper,
|
||||
private NoteHelper $noteHelper,
|
||||
private MassNotePreparator $massNotePreparator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find user stream records.
|
||||
*
|
||||
* @return RecordCollection<Note>
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function find(?string $userId, SearchParams $searchParams): RecordCollection
|
||||
{
|
||||
$userId ??= $this->user->getId();
|
||||
|
||||
$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, Acl\Permission::USER)) {
|
||||
throw new Forbidden("No user permission access.");
|
||||
}
|
||||
|
||||
$offset = $searchParams->getOffset() ?? 0;
|
||||
$maxSize = $searchParams->getMaxSize();
|
||||
|
||||
$baseBuilder = $this->queryHelper->buildBaseQueryBuilder($searchParams)
|
||||
->select($this->queryHelper->getUserQuerySelect())
|
||||
->leftJoin(Field::CREATED_BY)
|
||||
->order('number', Order::DESC)
|
||||
->limit(0, $offset + $maxSize + 1);
|
||||
|
||||
$queryList = [];
|
||||
|
||||
$this->buildSubscriptionQueries($user, $baseBuilder, $queryList, $searchParams);
|
||||
$this->buildSubscriptionSuperQuery($user, $baseBuilder, $queryList, $searchParams);
|
||||
$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);
|
||||
|
||||
return $this->processQueryList($user, $queryList, $offset, $maxSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find notes created by a user.
|
||||
*
|
||||
* @return RecordCollection<Note>
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function findOwn(string $userId, SearchParams $searchParams): RecordCollection
|
||||
{
|
||||
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
|
||||
|
||||
if (!$user) {
|
||||
throw new NotFound("User not found.");
|
||||
}
|
||||
|
||||
/** @noinspection PhpRedundantOptionalArgumentInspection */
|
||||
if (!$this->acl->checkUserPermission($user, Acl\Permission::USER)) {
|
||||
throw new Forbidden("No user permission access.");
|
||||
}
|
||||
|
||||
$offset = $searchParams->getOffset() ?? 0;
|
||||
$maxSize = $searchParams->getMaxSize();
|
||||
|
||||
$baseBuilder = $this->queryHelper->buildBaseQueryBuilder($searchParams)
|
||||
->select($this->queryHelper->getUserQuerySelect())
|
||||
->leftJoin(Field::CREATED_BY)
|
||||
->order('number', Order::DESC)
|
||||
->limit(0, $offset + $maxSize + 1);
|
||||
|
||||
$queryList[] = (clone $baseBuilder)
|
||||
->where(['createdById' => $user->getId()])
|
||||
->build();
|
||||
|
||||
return $this->processQueryList($user, $queryList, $offset, $maxSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string|int, mixed>
|
||||
*/
|
||||
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 Select[] $queryList
|
||||
*/
|
||||
private function buildSubscriptionQueriesPortal(
|
||||
User $user,
|
||||
SelectBuilder $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 SearchParams $searchParams
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildSubscriptionQueries(
|
||||
User $user,
|
||||
SelectBuilder $baseBuilder,
|
||||
array &$queryList,
|
||||
SearchParams $searchParams
|
||||
): void {
|
||||
|
||||
$ignoreWhereClause = $this->getSubscriptionIgnoreWhereClause($user);
|
||||
|
||||
$builder = clone $baseBuilder;
|
||||
|
||||
$builder
|
||||
->join(
|
||||
StreamSubscription::ENTITY_TYPE,
|
||||
'subscription',
|
||||
[
|
||||
'entityType:' => 'parentType',
|
||||
'entityId:' => 'parentId',
|
||||
'subscription.userId' => $user->getId(),
|
||||
]
|
||||
)
|
||||
->where($ignoreWhereClause);
|
||||
|
||||
if ($user->isPortal()) {
|
||||
$this->buildSubscriptionQueriesPortal($user, $builder, $queryList);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($searchParams->getPrimaryFilter() === self::FILTER_POSTS) {
|
||||
// No need access control as posts do not have a 'related' link.
|
||||
$queryList[] = $builder->build();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->buildAccessQueries($user, $builder, $queryList, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SearchParams $searchParams
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildSubscriptionSuperQuery(
|
||||
User $user,
|
||||
SelectBuilder $baseBuilder,
|
||||
array &$queryList,
|
||||
SearchParams $searchParams
|
||||
): void {
|
||||
|
||||
if ($user->isPortal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($searchParams->getPrimaryFilter() === self::FILTER_POSTS) {
|
||||
// Posts do not have a 'super-parent'.
|
||||
// They are not visible to super parent subscribers.
|
||||
// Bypassing for a performance reason.
|
||||
return;
|
||||
}
|
||||
|
||||
$ignoreWhereClause = $this->getSubscriptionIgnoreWhereClause($user);
|
||||
|
||||
$builder = clone $baseBuilder;
|
||||
|
||||
$builder
|
||||
->join(
|
||||
StreamSubscription::ENTITY_TYPE,
|
||||
'subscription',
|
||||
[
|
||||
// Improves performance significantly.
|
||||
'entityType' => Account::ENTITY_TYPE,
|
||||
//'entityType:' => 'superParentType',
|
||||
'entityId:' => 'superParentId',
|
||||
'subscription.userId' => $user->getId(),
|
||||
]
|
||||
)
|
||||
// NOT EXISTS sub-query would perform very slow.
|
||||
->leftJoin(
|
||||
StreamSubscription::ENTITY_TYPE,
|
||||
'subscriptionExclude',
|
||||
[
|
||||
'entityType:' => 'parentType',
|
||||
'entityId:' => 'parentId',
|
||||
'subscriptionExclude.userId' => $user->getId(),
|
||||
]
|
||||
)
|
||||
->where([
|
||||
'OR' => [
|
||||
'parentId!=:' => 'superParentId',
|
||||
'parentType!=:' => 'superParentType',
|
||||
],
|
||||
'subscriptionExclude.id' => null,
|
||||
])
|
||||
->where(['superParentType' => Account::ENTITY_TYPE])
|
||||
->where($ignoreWhereClause);
|
||||
|
||||
$this->buildAccessQueries($user, $builder, $queryList);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildPostedToUserQuery(User $user, SelectBuilder $baseBuilder, array &$queryList): void
|
||||
{
|
||||
$queryList[] = $this->queryHelper->buildPostedToUserQuery($user, $baseBuilder);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildPostedToPortalQuery(User $user, SelectBuilder $baseBuilder, array &$queryList): void
|
||||
{
|
||||
$query = $this->queryHelper->buildPostedToPortalQuery($user, $baseBuilder);
|
||||
|
||||
if (!$query) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryList[] = $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildPostedToTeamsQuery(User $user, SelectBuilder $baseBuilder, array &$queryList): void
|
||||
{
|
||||
$query = $this->queryHelper->buildPostedToTeamsQuery($user, $baseBuilder);
|
||||
|
||||
if (!$query) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryList[] = $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildPostedByUserQuery(User $user, SelectBuilder $baseBuilder, array &$queryList): void
|
||||
{
|
||||
$queryList[] = $this->queryHelper->buildPostedByUserQuery($user, $baseBuilder);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
*/
|
||||
private function buildPostedToGlobalQuery(User $user, SelectBuilder $baseBuilder, array &$queryList): void
|
||||
{
|
||||
$query = $this->queryHelper->buildPostedToGlobalQuery($user, $baseBuilder);
|
||||
|
||||
if (!$query) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryList[] = $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split into tree queries for all, team and own.
|
||||
* Note that only notes with 'related' and 'superParent' are subject
|
||||
* to access control. Notes with only 'parent' don't have teams and
|
||||
* users set.
|
||||
*
|
||||
* @param Select[] $queryList
|
||||
* @param bool $noParentFilter False is for 'superParent'.
|
||||
*/
|
||||
private function buildAccessQueries(
|
||||
User $user,
|
||||
SelectBuilder $baseBuilder,
|
||||
array &$queryList,
|
||||
bool $noParentFilter = false
|
||||
): void {
|
||||
|
||||
$onlyTeamEntityTypeList = $this->helper->getOnlyTeamEntityTypeList($user);
|
||||
$onlyOwnEntityTypeList = $this->helper->getOnlyOwnEntityTypeList($user);
|
||||
|
||||
$allBuilder = clone $baseBuilder;
|
||||
|
||||
$orWhere = [
|
||||
[
|
||||
'relatedId!=' => null,
|
||||
'relatedType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList),
|
||||
],
|
||||
];
|
||||
|
||||
$orWhere[] = $noParentFilter ?
|
||||
['relatedId=' => null] :
|
||||
[
|
||||
'relatedId=' => null,
|
||||
'parentType!=' => array_merge($onlyTeamEntityTypeList, $onlyOwnEntityTypeList),
|
||||
];
|
||||
|
||||
$allBuilder->where(['OR' => $orWhere]);
|
||||
|
||||
$queryList[] = $allBuilder->build();
|
||||
|
||||
if ($onlyTeamEntityTypeList !== []) {
|
||||
$teamBuilder = clone $baseBuilder;
|
||||
|
||||
$orWhere = [
|
||||
['relatedType=' => $onlyTeamEntityTypeList],
|
||||
];
|
||||
|
||||
if (!$noParentFilter) {
|
||||
$orWhere[] = [
|
||||
'relatedId=' => null,
|
||||
'parentType=' => $onlyTeamEntityTypeList,
|
||||
];
|
||||
}
|
||||
|
||||
$teamBuilder
|
||||
->where(['OR' => $orWhere])
|
||||
->where(
|
||||
// Separate sub-queries perform faster that a single with two LEFT JOINs inside.
|
||||
OrGroup::create(
|
||||
Cond::in(
|
||||
Expr::column('id'),
|
||||
SelectBuilder::create()
|
||||
->from('NoteTeam')
|
||||
->select('noteId')
|
||||
->where(['teamId' => $user->getTeamIdList()])
|
||||
->build()
|
||||
),
|
||||
Cond::in(
|
||||
Expr::column('id'),
|
||||
SelectBuilder::create()
|
||||
->from('NoteUser')
|
||||
->select('noteId')
|
||||
->where(['userId' => $user->getId()])
|
||||
->build()
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$queryList[] = $teamBuilder->build();
|
||||
}
|
||||
|
||||
if ($onlyOwnEntityTypeList !== []) {
|
||||
$ownBuilder = clone $baseBuilder;
|
||||
|
||||
$orWhere = [
|
||||
['relatedType=' => $onlyOwnEntityTypeList],
|
||||
];
|
||||
|
||||
if (!$noParentFilter) {
|
||||
$orWhere[] = [
|
||||
'relatedId=' => null,
|
||||
'parentType=' => $onlyOwnEntityTypeList,
|
||||
];
|
||||
}
|
||||
|
||||
$ownBuilder
|
||||
->where(['OR' => $orWhere])
|
||||
->where(
|
||||
Cond::in(
|
||||
Expr::column('id'),
|
||||
SelectBuilder::create()
|
||||
->from('NoteUser')
|
||||
->select('noteId')
|
||||
->where(['userId' => $user->getId()])
|
||||
->build()
|
||||
)
|
||||
);
|
||||
|
||||
$queryList[] = $ownBuilder->build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Select[] $queryList
|
||||
* @return RecordCollection<Note>
|
||||
*/
|
||||
private function processQueryList(
|
||||
User $user,
|
||||
array $queryList,
|
||||
int $offset,
|
||||
?int $maxSize
|
||||
): RecordCollection {
|
||||
|
||||
$builder = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->union()
|
||||
->all()
|
||||
->order('number', Order::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);
|
||||
$this->noteHelper->prepare($e);
|
||||
}
|
||||
|
||||
$this->massNotePreparator->prepare($collection);
|
||||
|
||||
return RecordCollection::createNoCount($collection, $maxSize);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user