Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View 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);
}
}

View 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);
}
}

View 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.");
}
}
}

View 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());
}
}

View 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;
}
}

View 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,
]);
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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,
]);
}
}

View 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;
}
}

View 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();
}
}

View 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());
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}

View 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', []) === [];
}
}

View 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);
}
}

View 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);
}
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

File diff suppressed because it is too large Load Diff

View 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);
}
}