diff --git a/application/Espo/Controllers/Notification.php b/application/Espo/Controllers/Notification.php index f80c8bbd63..c620add253 100644 --- a/application/Espo/Controllers/Notification.php +++ b/application/Espo/Controllers/Notification.php @@ -30,6 +30,7 @@ namespace Espo\Controllers; use Espo\Core\Name\Field; +use Espo\Entities\Notification as NotificationEntity; use Espo\Tools\Notification\RecordService as Service; use Espo\Core\Api\Request; @@ -60,6 +61,7 @@ class Notification extends RecordBase $maxSize = $searchParamsAux->getMaxSize(); $after = $request->getQueryParam('after'); + $beforeNumber = $request->getQueryParam('beforeNumber'); $searchParams = SearchParams ::create() @@ -76,19 +78,20 @@ class Notification extends RecordBase ->setValue($after) ->build() ); - } - $recordCollection = $this->getNotificationService()->get($this->user, $searchParams); + $recordCollection = $this->getNotificationService()->get($this->user, $searchParams, $beforeNumber); return $recordCollection->toApiOutput(); } + /** + * @throws BadRequest + * @throws Forbidden + */ public function getActionNotReadCount(): int { - $userId = $this->user->getId(); - - return $this->getNotificationService()->getNotReadCount($userId); + return $this->getNotificationService()->getNotReadCount($this->user); } public function postActionMarkAllRead(Request $request): bool diff --git a/application/Espo/Core/Upgrades/Migrations/V9_4/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V9_4/AfterUpgrade.php new file mode 100644 index 0000000000..0a3f75a4ab --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V9_4/AfterUpgrade.php @@ -0,0 +1,74 @@ +. + * + * 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\Core\Upgrades\Migrations\V9_4; + +use Espo\Core\Upgrades\Migration\Script; +use Espo\Entities\Preferences; +use Espo\Entities\User; +use Espo\ORM\EntityManager; + +class AfterUpgrade implements Script +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function run(): void + { + $this->updatePreferences(); + } + + private function updatePreferences(): void + { + $users = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->sth() + ->where([ + User::ATTR_IS_ACTIVE => true, + User::ATTR_TYPE => [ + User::TYPE_ADMIN, + User::TYPE_REGULAR, + User::TYPE_PORTAL, + ] + ]) + ->find(); + + foreach ($users as $user) { + $preferences = $this->entityManager->getRepositoryByClass(Preferences::class)->getById($user->getId()); + + if (!$preferences) { + continue; + } + + $preferences->set('notificationGrouping', true); + $this->entityManager->saveEntity($preferences); + } + } +} diff --git a/application/Espo/Entities/Note.php b/application/Espo/Entities/Note.php index 46b9f36d21..b184d952a1 100644 --- a/application/Espo/Entities/Note.php +++ b/application/Espo/Entities/Note.php @@ -64,6 +64,16 @@ class Note extends Entity public const TYPE_EMAIL_RECEIVED = 'EmailReceived'; public const TYPE_EMAIL_SENT = 'EmailSent'; + public const string DATA_ATTR_ADDED_ASSIGNED_USERS = 'addedAssignedUsers'; + public const string DATA_ATTR_REMOVED_ASSIGNED_USERS = 'removedAssignedUsers'; + public const string DATA_ATTR_ASSIGNED_USERS = 'assignedUsers'; + public const string DATA_ATTR_STATUS_VALUE = 'statusValue'; + public const string DATA_ATTR_STATUS_FIELD = 'statusField'; + public const string DATA_ATTR_ASSIGNED_USER_ID = 'assignedUserId'; + public const string DATA_ATTR_ASSIGNED_USER_NAME = 'assignedUserName'; + public const string DATA_ATTR_FIELDS = 'fields'; + public const string DATA_ATTR_ATTRIBUTES = 'attributes'; + private bool $aclIsProcessed = false; public function isPost(): bool diff --git a/application/Espo/Entities/Notification.php b/application/Espo/Entities/Notification.php index c93e31b302..68ea7ea6a0 100644 --- a/application/Espo/Entities/Notification.php +++ b/application/Espo/Entities/Notification.php @@ -54,6 +54,22 @@ class Notification extends Entity public const ATTR_ACTION_ID = 'actionId'; public const ATTR_NUMBER = 'number'; + public const string ATTR_RELATED_TYPE = 'relatedType'; + public const string ATTR_RELATED_ID = 'relatedId'; + public const string ATTR_RELATED_PARENT_TYPE = 'relatedParentType'; + public const string ATTR_RELATED_PARENT_ID = 'relatedParentId'; + + public const string FIELD_DATA = 'data'; + public const string FIELD_MESSAGE = 'message'; + public const string FIELD_TYPE = 'type'; + public const string FIELD_RELATED_PARENT = 'relatedParent'; + public const string FIELD_IS_FEATURED = 'isFeatured'; + + public const string GROUP_TYPE_NOTE = Notification::TYPE_NOTE; + public const string GROUP_TYPE_EMAIL_RECEIVED = Notification::TYPE_EMAIL_RECEIVED; + + public const string DATE_ATTR_NOTE_ID = 'noteId'; + public function getType(): ?string { return $this->get('type'); @@ -128,6 +144,12 @@ class Notification extends Entity return $this; } + public function getRelatedParent(): ?LinkParent + { + /** @var ?LinkParent */ + return $this->getValueObject('relatedParent'); + } + public function setRelatedParent(LinkParent|Entity|null $relatedParent): self { if ($relatedParent instanceof LinkParent) { @@ -185,8 +207,27 @@ class Notification extends Entity return $this->get('actionId'); } + /** + * @internal + */ public function setGroupedCount(?int $groupedCount): self { return $this->set('groupedCount', $groupedCount); } + + /** + * @internal + */ + public function getGroupType(): ?string + { + return $this->get('groupType'); + } + + /** + * @since 9.4.0 + */ + public function setIsFeatured(bool $isFeatured): self + { + return $this->set(self::FIELD_IS_FEATURED, $isFeatured); + } } diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 44ff93d0a0..6035a87c9e 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -338,7 +338,8 @@ "Reaction Removed": "Reaction Removed", "Reactions": "Reactions", "Lock": "Lock", - "Unlock": "Unlock" + "Unlock": "Unlock", + "Assigned": "Assigned" }, "messages": { "checkLogsForDetails": "Check logs for details.", @@ -566,7 +567,12 @@ "emailInbox": "{user} added email {entity} to your inbox", "userPostReaction": "{user} reacted to your {post}", "addedToCollaborators": "{user} added you as a collaborator to {entityType} {entity}", - "userPostInParentReaction": "{user} reacted to your {post} in {entityType} {entity}" + "userPostInParentReaction": "{user} reacted to your {post} in {entityType} {entity}", + "groupUpdatesMultiple": "{entityType} {entity} – {number} new activities", + "groupUpdatesOne": "{entityType} {entity} – new activity", + "groupUpdates": "{entityType} {entity}", + "groupEmailsReceivedNew": "Emails received – {number} new", + "groupEmailsReceived": "Emails received" }, "streamMessages": { "post": "{user} posted on {entityType} {entity}", diff --git a/application/Espo/Resources/i18n/en_US/Preferences.json b/application/Espo/Resources/i18n/en_US/Preferences.json index 0cee27c180..276433e4b6 100644 --- a/application/Espo/Resources/i18n/en_US/Preferences.json +++ b/application/Espo/Resources/i18n/en_US/Preferences.json @@ -17,6 +17,7 @@ "assignmentEmailNotificationsIgnoreEntityTypeList": "Email assignment notifications", "reactionNotifications": "In-app notifications about reactions", "reactionNotificationsNotFollowed": "Notifications about reactions for non-followed records", + "notificationGrouping": "Group notifications", "autoFollowEntityTypeList": "Global Auto-Follow", "signature": "Email Signature", "dashboardTabList": "Tab List", diff --git a/application/Espo/Resources/layouts/Preferences/detail.json b/application/Espo/Resources/layouts/Preferences/detail.json index d1ef1d1920..9cf04365dc 100644 --- a/application/Espo/Resources/layouts/Preferences/detail.json +++ b/application/Espo/Resources/layouts/Preferences/detail.json @@ -147,6 +147,10 @@ [ {"name": "reactionNotifications"}, {"name": "reactionNotificationsNotFollowed"} + ], + [ + {"name": "notificationGrouping"}, + false ] ] } diff --git a/application/Espo/Resources/layouts/Preferences/detailPortal.json b/application/Espo/Resources/layouts/Preferences/detailPortal.json index 09986b64d7..4debec5ab6 100644 --- a/application/Espo/Resources/layouts/Preferences/detailPortal.json +++ b/application/Espo/Resources/layouts/Preferences/detailPortal.json @@ -31,6 +31,10 @@ [ {"name": "reactionNotifications"}, {"name": "reactionNotificationsNotFollowed"} + ], + [ + {"name": "notificationGrouping"}, + false ] ] } diff --git a/application/Espo/Resources/metadata/clientDefs/Notification.json b/application/Espo/Resources/metadata/clientDefs/Notification.json index 144fee03a4..4f15daec10 100644 --- a/application/Espo/Resources/metadata/clientDefs/Notification.json +++ b/application/Espo/Resources/metadata/clientDefs/Notification.json @@ -2,7 +2,8 @@ "controller": "controllers/notification", "acl": "acl/notification", "aclPortal": "acl-portal/notification", - "collection": "collections/note", + "model": "models/notification", + "collection": "collections/notification", "itemViews": { "System": "views/notification/items/system", "EmailInbox": "views/notification/items/email-inbox" diff --git a/application/Espo/Resources/metadata/entityDefs/Notification.json b/application/Espo/Resources/metadata/entityDefs/Notification.json index a02e223aa4..9ded691ded 100644 --- a/application/Espo/Resources/metadata/entityDefs/Notification.json +++ b/application/Espo/Resources/metadata/entityDefs/Notification.json @@ -46,12 +46,33 @@ "readOnly": true, "index": true }, + "isFeatured": { + "type": "bool", + "readOnly": true + }, "groupedCount": { "type": "int", "notStorable": true, "readOnly": true, "utility": true }, + "groupType": { + "type": "enum", + "notStorable": true, + "readOnly": true, + "utility": true, + "options": [ + "", + "Note", + "EmailReceived" + ] + }, + "groupedUnreadCount": { + "type": "int", + "notStorable": true, + "readOnly": true, + "utility": true + }, "createdBy": { "type": "link", "readOnly": true, diff --git a/application/Espo/Resources/metadata/entityDefs/Preferences.json b/application/Espo/Resources/metadata/entityDefs/Preferences.json index 2df3bf862c..47efa01c97 100644 --- a/application/Espo/Resources/metadata/entityDefs/Preferences.json +++ b/application/Espo/Resources/metadata/entityDefs/Preferences.json @@ -115,6 +115,10 @@ "type": "bool", "default": false }, + "notificationGrouping": { + "type": "bool", + "default": true + }, "autoFollowEntityTypeList": { "type": "multiEnum", "view": "views/preferences/fields/auto-follow-entity-type-list", diff --git a/application/Espo/Resources/routes.json b/application/Espo/Resources/routes.json index c4619f9cc0..455920b1b3 100644 --- a/application/Espo/Resources/routes.json +++ b/application/Espo/Resources/routes.json @@ -355,6 +355,16 @@ "method": "get", "actionClassName": "Espo\\Tools\\Notification\\Api\\GetGroup" }, + { + "route": "/Notification/group", + "method": "get", + "actionClassName": "Espo\\Tools\\Notification\\Api\\GetGroupAll" + }, + { + "route": "/Notification/group/:id", + "method": "delete", + "actionClassName": "Espo\\Tools\\Notification\\Api\\DeleteGroup" + }, { "route": "/EmailTemplate/:id/prepare", "method": "post", diff --git a/application/Espo/Tools/Notification/Api/DeleteGroup.php b/application/Espo/Tools/Notification/Api/DeleteGroup.php new file mode 100644 index 0000000000..065cc037eb --- /dev/null +++ b/application/Espo/Tools/Notification/Api/DeleteGroup.php @@ -0,0 +1,56 @@ +. + * + * 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\Notification\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\Tools\Notification\GroupAllService; + +/** + * @noinspection PhpUnused + */ +class DeleteGroup implements Action +{ + public function __construct( + private GroupAllService $service, + ) {} + + public function process(Request $request): Response + { + $id = $request->getRouteParam('id') ?? throw new BadRequest(); + + $this->service->remove($id); + + return ResponseComposer::json(true); + } +} diff --git a/application/Espo/Tools/Notification/Api/GetGroupAll.php b/application/Espo/Tools/Notification/Api/GetGroupAll.php new file mode 100644 index 0000000000..daac06dd60 --- /dev/null +++ b/application/Espo/Tools/Notification/Api/GetGroupAll.php @@ -0,0 +1,79 @@ +. + * + * 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\Notification\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\SearchParamsFetcher; +use Espo\Core\Select\Where\Item as WhereItem; +use Espo\Entities\Notification; +use Espo\Tools\Notification\GroupAllService; + +/** + * @noinspection PhpUnused + */ +class GetGroupAll implements Action +{ + public function __construct( + private GroupAllService $service, + private SearchParamsFetcher $searchParamsFetcher, + ) {} + + public function process(Request $request): Response + { + $type = $request->getQueryParam('type') ?? throw new BadRequest("No `type`."); + $id = $request->getQueryParam('id') ?? throw new BadRequest("No `id`."); + + $searchParams = $this->searchParamsFetcher->fetch($request); + + $beforeNumber = $request->getQueryParam('beforeNumber'); + + if ($beforeNumber) { + $searchParams = $searchParams + ->withWhereAdded( + WhereItem + ::createBuilder() + ->setAttribute(Notification::ATTR_NUMBER) + ->setType(WhereItem\Type::LESS_THAN) + ->setValue($beforeNumber) + ->build() + ); + } + + $collection = $this->service->get($type, $id, $searchParams); + + return ResponseComposer::json( + $collection->toApiOutput() + ); + } +} diff --git a/application/Espo/Tools/Notification/GroupAllService.php b/application/Espo/Tools/Notification/GroupAllService.php new file mode 100644 index 0000000000..29382446ca --- /dev/null +++ b/application/Espo/Tools/Notification/GroupAllService.php @@ -0,0 +1,281 @@ +. + * + * 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\Notification; + +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\Select\SelectBuilderFactory; +use Espo\Entities\Notification; +use Espo\Entities\User; +use Espo\ORM\Collection; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\DeleteBuilder; +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 GroupAllService +{ + public function __construct( + private EntityManager $entityManager, + private Acl $acl, + private User $user, + private RecordService $recordService, + private SelectBuilderFactory $selectBuilderFactory, + ) {} + + /** + * @return RecordCollection + * @throws BadRequest + * @throws Forbidden + */ + public function get(string $type, string $groupId, SearchParams $searchParams): RecordCollection + { + $collection = null; + + if ($type == Notification::GROUP_TYPE_NOTE) { + $collection = $this->getNote($groupId, $searchParams); + } else if ($type == Notification::GROUP_TYPE_EMAIL_RECEIVED) { + $collection = $this->getEmailReceived($searchParams); + } + + if (!$collection) { + throw new BadRequest("Bad group."); + } + + $this->markAsRead($collection); + + return $collection; + } + + /** + * @return RecordCollection + * @throws BadRequest + * @throws Forbidden + */ + private function getNote(string $groupId, SearchParams $searchParams): RecordCollection + { + if (substr_count($groupId, '_') < 2) { + throw new BadRequest("Bad ID."); + } + + [, $entityType, $id] = explode('_', $groupId, 3); + + if (!$this->acl->checkScope($entityType)) { + /** @var Collection $collection */ + $collection = $this->entityManager->getCollectionFactory()->create(Notification::ENTITY_TYPE); + + return RecordCollection::create($collection, 0); + } + + $builder = $this->prepareBuilder($searchParams); + + $query = $builder + ->where([ + Notification::FIELD_TYPE => Notification::TYPE_NOTE, + Notification::ATTR_RELATED_PARENT_TYPE => $entityType, + Notification::ATTR_RELATED_PARENT_ID => $id, + ]) + ->build(); + + [$collection, $total] = $this->runQuery($query); + + $collection = $this->recordService->prepareCollection($collection, $this->user); + + return RecordCollection::create($collection, $total); + } + + /** + * @return RecordCollection + * @throws BadRequest + * @throws Forbidden + */ + private function getEmailReceived(SearchParams $searchParams): RecordCollection + { + $builder = $this->prepareBuilder($searchParams); + + $query = $builder + ->where([ + Notification::FIELD_TYPE => Notification::TYPE_EMAIL_RECEIVED, + ]) + ->build(); + + [$collection, $total] = $this->runQuery($query); + + $collection = $this->recordService->prepareCollection($collection, $this->user); + + return RecordCollection::create($collection, $total); + } + + /** + * @return array{0: Collection, 1: int} + */ + private function runQuery(Select $query): array + { + $collection = $this->entityManager + ->getRDBRepositoryByClass(Notification::class) + ->clone($query) + ->find(); + + $total = $this->entityManager + ->getRDBRepositoryByClass(Notification::class) + ->clone($query) + ->count(); + + return [$collection, $total]; + } + + /** + * @param string[] $ids + */ + private function markAsReadByIds(array $ids): void + { + if ($ids === []) { + return; + } + + $query = $this->entityManager + ->getQueryBuilder() + ->update() + ->in(Notification::ENTITY_TYPE) + ->set([Notification::ATTR_READ => true]) + ->where([Notification::ATTR_USER_ID => $this->user->getId()]) + ->where( + Cond::in(Expr::column(Attribute::ID), $ids) + ) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } + + /** + * @param RecordCollection $collection + */ + private function markAsRead(RecordCollection $collection): void + { + $ids = []; + + foreach ($collection->getCollection() as $entity) { + $ids[] = $entity->getId(); + } + + $this->markAsReadByIds($ids); + } + + /** + * @return SelectBuilder + * @throws BadRequest + * @throws Forbidden + */ + private function prepareBuilder(SearchParams $searchParams): SelectBuilder + { + return $this->selectBuilderFactory + ->create() + ->from(Notification::ENTITY_TYPE) + ->withSearchParams($searchParams) + ->withComplexExpressionsForbidden() + ->buildQueryBuilder() + ->where([ + Notification::ATTR_USER_ID => $this->user->getId(), + ]); + } + + /** + * @throws BadRequest + */ + public function remove(string $groupId): void + { + if (substr_count($groupId, '_') < 1) { + throw new BadRequest("Bad ID."); + } + + [$type,] = explode('_', $groupId, 2); + + if ($type == Notification::GROUP_TYPE_NOTE) { + $this->removeNote($groupId); + + return; + } + + if ($type == Notification::GROUP_TYPE_EMAIL_RECEIVED) { + $this->removeEmailReceived(); + + return; + } + + throw new BadRequest("Bad group type."); + } + + /** + * @throws BadRequest + */ + private function removeNote(string $groupId): void + { + if (substr_count($groupId, '_') < 2) { + throw new BadRequest("Bad ID."); + } + + [, $entityType, $id] = explode('_', $groupId, 3); + + $query = DeleteBuilder::create() + ->from(Notification::ENTITY_TYPE) + ->where([ + Notification::ATTR_USER_ID => $this->user->getId(), + ]) + ->where([ + Notification::FIELD_TYPE => Notification::TYPE_NOTE, + Notification::ATTR_RELATED_PARENT_TYPE => $entityType, + Notification::ATTR_RELATED_PARENT_ID => $id, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } + + private function removeEmailReceived(): void + { + $query = DeleteBuilder::create() + ->from(Notification::ENTITY_TYPE) + ->where([ + Notification::ATTR_USER_ID => $this->user->getId(), + ]) + ->where([ + Notification::FIELD_TYPE => Notification::TYPE_EMAIL_RECEIVED, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } +} diff --git a/application/Espo/Tools/Notification/RecordService.php b/application/Espo/Tools/Notification/RecordService.php index 769036a39e..05dedaaf41 100644 --- a/application/Espo/Tools/Notification/RecordService.php +++ b/application/Espo/Tools/Notification/RecordService.php @@ -48,12 +48,35 @@ use Espo\ORM\EntityManager; use Espo\ORM\Name\Attribute; 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\Selection; use Espo\ORM\Query\Part\WhereItem; use Espo\ORM\Query\SelectBuilder; +use Espo\ORM\Query\Union; +use Espo\ORM\Query\UnionBuilder; +use Espo\ORM\Repository\RDBSelectBuilder; use Espo\Tools\Stream\NoteAccessControl; +use Espo\Tools\User\PreferencesProvider; +use PDO; +use UnexpectedValueException; class RecordService { + private const string COLUMN_GROUP_TYPE = 'groupType'; + private const string COLUMN_GROUP_UNREAD_COUNT = 'groupedUnreadCount'; + + /** @var string[] */ + private array $noGroupAttributes = [ + Attribute::DELETED, + Field::CREATED_BY . 'Id', + Notification::ATTR_ACTION_ID, + Notification::FIELD_DATA, + Notification::FIELD_MESSAGE, + Notification::FIELD_TYPE, + Notification::ATTR_RELATED_ID, + Notification::ATTR_RELATED_TYPE, + ]; + public function __construct( private EntityManager $entityManager, private Acl $acl, @@ -61,6 +84,7 @@ class RecordService private NoteAccessControl $noteAccessControl, private SelectBuilderFactory $selectBuilderFactory, private Config $config, + private PreferencesProvider $preferencesProvider, ) {} /** @@ -70,20 +94,14 @@ class RecordService * @throws Error * @throws BadRequest * @throws Forbidden + * + * @internal */ - public function get(User $user, SearchParams $searchParams): RecordCollection + public function get(User $user, SearchParams $searchParams, ?string $beforeNumber = null): RecordCollection { - $queryBuilder = $this->selectBuilderFactory - ->create() - ->from(Notification::ENTITY_TYPE) - ->withSearchParams($searchParams) - ->buildQueryBuilder() - ->where([Notification::ATTR_USER_ID => $user->getId()]) - ->order(Notification::ATTR_NUMBER, SearchParams::ORDER_DESC); - - if ($this->isGroupingEnabled()) { - $queryBuilder->where($this->getActionIdWhere($user->getId())); - } + $queryBuilder = $this->isGroupingEnabled($user) ? + $this->prepareGroupingQueryBuilder($user, $searchParams, beforeNumber: $beforeNumber) : + $this->prepareQueryBuilder($user, $searchParams, beforeNumber: $beforeNumber); $offset = $searchParams->getOffset(); $limit = $searchParams->getMaxSize(); @@ -92,23 +110,16 @@ class RecordService $queryBuilder->limit($offset, $limit + 1); } - $ignoreScopeList = $this->getIgnoreScopeList(); - - if ($ignoreScopeList !== []) { - $queryBuilder->where([ - 'OR' => [ - 'relatedParentType' => null, - 'relatedParentType!=' => $ignoreScopeList, - ], - ]); - } - $query = $queryBuilder->build(); - $collection = $this->entityManager - ->getRDBRepositoryByClass(Notification::class) - ->clone($query) - ->find(); + if ($query instanceof Union) { + $collection = $this->fetchAndPrepareCollectionFromUnion($query); + } else { + $collection = $this->entityManager + ->getRDBRepositoryByClass(Notification::class) + ->clone($query) + ->find(); + } if (!$collection instanceof EntityCollection) { throw new Error("Collection is not instance of EntityCollection."); @@ -116,7 +127,7 @@ class RecordService $collection = $this->prepareCollection($collection, $user); - $groupedCountMap = $this->getGroupedCountMap($collection, $user->getId()); + $groupedCountMap = $this->getActionGroupedCountMap($collection, $user->getId()); $ids = []; $actionIds = []; @@ -126,11 +137,19 @@ class RecordService break; } - $ids[] = $entity->getId(); + if (!$entity->getGroupType()) { + $ids[] = $entity->getId(); + } $groupedCount = null; - if ($entity->getActionId() && $this->isGroupingEnabled()) { + if ($this->isGroupingEnabled($user) && $entity->getGroupType()) { + $groupedCount = -1; + + $entity->loadParentNameField(Notification::FIELD_RELATED_PARENT); + } + + if ($entity->getActionId() && $this->isActionGroupingEnabled()) { $actionIds[] = $entity->getActionId(); $groupedCount = $groupedCountMap[$entity->getActionId()] ?? 0; @@ -252,33 +271,35 @@ class RecordService $entity->set('noteData', $note->getValueMap()); } - public function getNotReadCount(string $userId): int + /** + * @throws BadRequest + * @throws Forbidden + */ + public function getNotReadCount(User $user): int { - $whereClause = [ - Notification::ATTR_USER_ID => $userId, - Notification::ATTR_READ => false, - ]; + $searchParams = SearchParams::create(); - $ignoreScopeList = $this->getIgnoreScopeList(); + $queryBuilder = $this->isGroupingEnabled($user) ? + $this->prepareGroupingQueryBuilder($user, $searchParams, true) : + $this->prepareQueryBuilder($user, $searchParams, true); - if (count($ignoreScopeList)) { - $whereClause[] = [ - 'OR' => [ - 'relatedParentType' => null, - 'relatedParentType!=' => $ignoreScopeList, - ] - ]; + $countQuery = $this->entityManager->getQueryBuilder() + ->select() + ->fromQuery($queryBuilder->build(), 'q') + ->select('COUNT:(q.id)', 'c') + ->build(); + + $sth = $this->entityManager->getQueryExecutor()->execute($countQuery); + + $row = $sth->fetch(PDO::FETCH_ASSOC); + + $count = $row['c'] ?? null; + + if (!is_int($count)) { + throw new UnexpectedValueException(); } - $builder = $this->entityManager - ->getRDBRepositoryByClass(Notification::class) - ->where($whereClause); - - if ($this->isGroupingEnabled()) { - $builder->where($this->getActionIdWhere($userId)); - } - - return $builder->count(); + return $count; } public function markAllRead(string $userId): bool @@ -422,9 +443,9 @@ class RecordService * @param EntityCollection $collection * @return array */ - private function getGroupedCountMap(EntityCollection $collection, string $userId): array + private function getActionGroupedCountMap(EntityCollection $collection, string $userId): array { - if (!$this->isGroupingEnabled()) { + if (!$this->isActionGroupingEnabled()) { return []; } @@ -464,7 +485,7 @@ class RecordService return $groupedCountMap; } - private function isGroupingEnabled(): bool + private function isActionGroupingEnabled(): bool { // @todo Param in preferences? return (bool) ($this->config->get('notificationGrouping') ?? true); @@ -483,4 +504,394 @@ class RecordService $entity->set('createdByName', $createdByName); } } + + private function isGroupingEnabled(User $user): bool + { + return $this->preferencesProvider->tryGet($user->getId())?->get('notificationGrouping') ?? false; + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function prepareGroupingQueryBuilder( + User $user, + SearchParams $searchParams, + bool $notRead = false, + ?string $beforeNumber = null, + ): UnionBuilder { + + $noteGroupBuilder = $this->prepareNoteGroupBuilder( + searchParams: $searchParams, + user: $user, + notRead: $notRead, + beforeNumber: $beforeNumber, + ); + + $emailGroupBuilder = $this->prepareEmailGroupBuilder( + searchParams: $searchParams, + user: $user, + notRead: $notRead, + beforeNumber: $beforeNumber, + ); + + $restBuilder = $this->prepareRestBuilder( + searchParams: $searchParams, + user: $user, + notRead: $notRead, + beforeNumber: $beforeNumber, + ); + + return UnionBuilder::create() + ->query($noteGroupBuilder->build()) + ->query($emailGroupBuilder->build()) + ->query($restBuilder->build()) + ->order(Notification::ATTR_NUMBER, Order::DESC); + } + + /** + * @param RDBSelectBuilder|SelectBuilder $builder + */ + private function applyRelatedAccess(RDBSelectBuilder|SelectBuilder $builder): void + { + $ignoreScopeList = $this->getIgnoreScopeList(); + + if ($ignoreScopeList === []) { + return; + } + + $builder->where([ + 'OR' => [ + Notification::ATTR_RELATED_PARENT_TYPE => null, + Notification::ATTR_RELATED_PARENT_TYPE . '!=' => $ignoreScopeList, + ], + ]); + } + + /** + * @return EntityCollection + */ + private function fetchAndPrepareCollectionFromUnion(Union $query): EntityCollection + { + $sth = $this->entityManager->getQueryExecutor()->execute($query); + + /** @var EntityCollection $collection */ + $collection = $this->entityManager->getCollectionFactory()->create(Notification::ENTITY_TYPE); + + while ($row = $sth->fetch(PDO::FETCH_ASSOC)) { + $entity = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew(); + + $entity->setMultiple($row); + $entity->setAsFetched(); + + $collection[] = $entity; + } + + return $collection; + } + + /** + * @return Selection[] + */ + private function getNullNoGroupSelections(): array + { + return array_map(function ($attribute) { + return Selection::create(Expr::value(null), $attribute); + }, $this->noGroupAttributes); + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function prepareQueryBuilder( + User $user, + SearchParams $searchParams, + bool $notRead = false, + ?string $beforeNumber = null, + ): SelectBuilder { + + $builder = $this->selectBuilderFactory + ->create() + ->from(Notification::ENTITY_TYPE) + ->withSearchParams($searchParams) + ->buildQueryBuilder() + ->where([Notification::ATTR_USER_ID => $user->getId()]) + ->order(Notification::ATTR_NUMBER, SearchParams::ORDER_DESC); + + if ($notRead) { + $builder->where([Notification::ATTR_READ => false]); + } + + if ($beforeNumber) { + $builder->where([Notification::ATTR_NUMBER . '<' => $beforeNumber]); + } + + $this->applyRelatedAccess($builder); + + if ($this->isActionGroupingEnabled()) { + $builder->where($this->getActionIdWhere($user->getId())); + } + + return $builder; + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function prepareNoteGroupBuilder( + SearchParams $searchParams, + User $user, + bool $notRead, + ?string $beforeNumber, + ): SelectBuilder { + + $builder = $this->selectBuilderFactory + ->create() + ->from(Notification::ENTITY_TYPE) + ->withSearchParams($searchParams) + ->buildQueryBuilder() + ->select([ + Selection::create( + Expr::value(Notification::GROUP_TYPE_NOTE), + self::COLUMN_GROUP_TYPE + ), + Selection::create( + Expr::concat( + Expr::value(Notification::GROUP_TYPE_NOTE), + Expr::value('_'), + Expr::column(Notification::ATTR_RELATED_PARENT_TYPE), + Expr::value('_'), + Expr::column(Notification::ATTR_RELATED_PARENT_ID), + ), + Attribute::ID + ), + Selection::create( + Expr::max(Expr::column(Notification::ATTR_NUMBER)), + Notification::ATTR_NUMBER + ), + Notification::ATTR_RELATED_PARENT_ID, + Notification::ATTR_RELATED_PARENT_TYPE, + Selection::create( + Expr::switch( + Expr::greater(Expr::sum(Expr::not(Expr::column(Notification::ATTR_READ))), 0), + Expr::value(false), + Expr::value(true), + ), + Notification::ATTR_READ + ), + Selection::create( + Expr::sum( + Expr::switch( + Expr::column(Notification::ATTR_READ), + Expr::value(0), + Expr::value(1), + ), + ), + self::COLUMN_GROUP_UNREAD_COUNT + ), + Selection::create( + Expr::max(Expr::column(Field::CREATED_AT)), + Field::CREATED_AT, + ), + Selection::create( + Expr::switch( + Expr::greater( + Expr::sum( + Expr::and( + Expr::not(Expr::column(Notification::ATTR_READ)), + Expr::column(Notification::FIELD_IS_FEATURED), + ) + ), + 0 + ), + true, + false, + ), + Notification::FIELD_IS_FEATURED + ), + ...$this->getNullNoGroupSelections(), + ]) + ->where([ + Notification::ATTR_RELATED_PARENT_ID . '!=' => null, + Notification::FIELD_TYPE => Notification::TYPE_NOTE, + ]) + ->where([Notification::ATTR_USER_ID => $user->getId()]) + ->group(Notification::ATTR_RELATED_PARENT_ID) + ->group(Notification::ATTR_RELATED_PARENT_TYPE) + ->order(Notification::ATTR_NUMBER, Order::DESC); + + if ($beforeNumber) { + $builder->having( + Cond::less( + Expr::max(Expr::column(Notification::ATTR_NUMBER)), + Expr::value($beforeNumber) + ) + ); + } + + $this->applyRelatedAccess($builder); + + if ($notRead) { + $builder->where([Notification::ATTR_READ => false]); + } + + return $builder; + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function prepareEmailGroupBuilder( + SearchParams $searchParams, + User $user, + bool $notRead, + ?string $beforeNumber, + ): SelectBuilder { + + $builder = $this->selectBuilderFactory + ->create() + ->from(Notification::ENTITY_TYPE) + ->withSearchParams($searchParams) + ->buildQueryBuilder() + ->select([ + Selection::create( + Expr::value(Notification::GROUP_TYPE_EMAIL_RECEIVED), + self::COLUMN_GROUP_TYPE + ), + Selection::create( + Expr::concat( + Expr::value(Notification::GROUP_TYPE_EMAIL_RECEIVED), + Expr::value('_'), + ), + Attribute::ID + ), + Selection::create( + Expr::max(Expr::column(Notification::ATTR_NUMBER)), + Notification::ATTR_NUMBER + ), + Selection::create( + Expr::value(null), + Notification::ATTR_RELATED_PARENT_ID, + ), + Selection::create( + Expr::value(null), + Notification::ATTR_RELATED_PARENT_TYPE, + ), + Selection::create( + Expr::switch( + Expr::greater(Expr::sum(Expr::not(Expr::column(Notification::ATTR_READ))), 0), + Expr::value(false), + Expr::value(true), + ), + Notification::ATTR_READ + ), + Selection::create( + Expr::sum( + Expr::switch( + Expr::column(Notification::ATTR_READ), + Expr::value(0), + Expr::value(1), + ), + ), + self::COLUMN_GROUP_UNREAD_COUNT + ), + Selection::create( + Expr::max(Expr::column(Field::CREATED_AT)), + Field::CREATED_AT, + ), + Selection::create( + Expr::value(null), + Notification::FIELD_IS_FEATURED, + ), + ...$this->getNullNoGroupSelections(), + ]) + ->where([ + Notification::FIELD_TYPE => Notification::TYPE_EMAIL_RECEIVED, + ]) + ->where([Notification::ATTR_USER_ID => $user->getId()]) + ->group(Notification::FIELD_TYPE) + ->order(Notification::ATTR_NUMBER, Order::DESC); + + if ($beforeNumber) { + $builder->having( + Cond::less( + Expr::max(Expr::column(Notification::ATTR_NUMBER)), + Expr::value($beforeNumber) + ) + ); + } + + if ($notRead) { + $builder->where([Notification::ATTR_READ => false]); + } + + return $builder; + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function prepareRestBuilder( + SearchParams $searchParams, + User $user, + bool $notRead, + ?string $beforeNumber, + ): SelectBuilder { + + $builder = $this->selectBuilderFactory + ->create() + ->from(Notification::ENTITY_TYPE) + ->withSearchParams($searchParams) + ->buildQueryBuilder() + ->select([ + Selection::create( + Expr::value(null), + self::COLUMN_GROUP_TYPE, + ), + Attribute::ID, + Notification::ATTR_NUMBER, + Notification::ATTR_RELATED_PARENT_ID, + Notification::ATTR_RELATED_PARENT_TYPE, + Notification::ATTR_READ, + Selection::create( + Expr::switch( + Expr::column(Notification::ATTR_READ), + Expr::value(0), + Expr::value(1), + ), + self::COLUMN_GROUP_UNREAD_COUNT + ), + Field::CREATED_AT, + Selection::create( + Expr::value(null), + Notification::FIELD_IS_FEATURED, + ), + ...$this->noGroupAttributes, + ]) + ->where([ + [ + 'OR' => [ + Notification::FIELD_TYPE . '!=' => Notification::TYPE_NOTE, + Notification::ATTR_RELATED_PARENT_ID => null, + ], + ], + Notification::FIELD_TYPE . '!=' => Notification::TYPE_EMAIL_RECEIVED, + ]) + ->where([Notification::ATTR_USER_ID => $user->getId()]) + ->order(Notification::ATTR_NUMBER, Order::DESC); + + if ($notRead) { + $builder->where([Notification::ATTR_READ => false]); + } + + if ($beforeNumber) { + $builder->where([Notification::ATTR_NUMBER . '<' => $beforeNumber]); + } + + return $builder; + } } diff --git a/application/Espo/Tools/Notification/Service.php b/application/Espo/Tools/Notification/Service.php index 08c985d121..493df4e9e2 100644 --- a/application/Espo/Tools/Notification/Service.php +++ b/application/Espo/Tools/Notification/Service.php @@ -43,6 +43,7 @@ use Espo\Modules\Crm\Entities\CaseObj; use Espo\ORM\EntityManager; use Espo\ORM\Name\Attribute; use Espo\Tools\Notification\HookProcessor\Params; +use stdClass; class Service { @@ -91,7 +92,7 @@ class Service $collection = $this->entityManager->getCollectionFactory()->create(); $users = $this->entityManager - ->getRDBRepository(User::ENTITY_TYPE) + ->getRDBRepositoryByClass(User::class) ->select([ Attribute::ID, User::ATTR_TYPE, @@ -124,10 +125,11 @@ class Service $actionId = $params?->actionId; - if ( - in_array($note->getType(), [Note::TYPE_ASSIGN, Note::TYPE_CREATE]) && - ($note->getData()->assignedUserId ?? null) === $user->getId() - ) { + $isFeatured = false; + + if ($this->isUserTargetAssignee($note, $user)) { + $isFeatured = true; + // Do not group notifications about assignment. $actionId = null; } @@ -137,7 +139,7 @@ class Service $notification ->set(Attribute::ID, $this->idGenerator->generate()) ->set(Field::CREATED_AT, $now) - ->setData(['noteId' => $note->getId()]) + ->setData([Notification::DATE_ATTR_NOTE_ID => $note->getId()]) ->setType(Notification::TYPE_NOTE) ->setUserId($user->getId()) ->setRelated(LinkParent::fromEntity($note)) @@ -145,7 +147,8 @@ class Service $note->getParentType() && $note->getParentId() ? LinkParent::create($note->getParentType(), $note->getParentId()) : null ) - ->setActionId($actionId); + ->setActionId($actionId) + ->setIsFeatured($isFeatured); $collection[] = $notification; } @@ -184,4 +187,37 @@ class Service return true; } + + + private function isUserTargetAssignee(Note $note, User $user): bool + { + if (!in_array($note->getType(), [Note::TYPE_ASSIGN, Note::TYPE_CREATE])) { + return false; + } + + $noteData = $note->getData(); + + if (($noteData->{Note::DATA_ATTR_ASSIGNED_USER_ID} ?? null) === $user->getId()) { + return true; + } + + $assignedUsers = $noteData->{Note::DATA_ATTR_ASSIGNED_USERS} ?? + $noteData->{Note::DATA_ATTR_ADDED_ASSIGNED_USERS} ?? null; + + if (!is_array($assignedUsers)) { + return false; + } + + foreach ($assignedUsers as $item) { + if (!$item instanceof stdClass) { + continue; + } + + if (($item->{Attribute::ID} ?? null) === $user->getId()) { + return true; + } + } + + return false; + } } diff --git a/application/Espo/Tools/Stream/Service.php b/application/Espo/Tools/Stream/Service.php index 06b358ce35..4d05a2f95c 100644 --- a/application/Espo/Tools/Stream/Service.php +++ b/application/Espo/Tools/Stream/Service.php @@ -29,6 +29,7 @@ namespace Espo\Tools\Stream; +use Espo\Core\Acl\AssignmentChecker\Helper as AsssignmentHelper; use Espo\Core\Field\DateTime; use Espo\Core\Field\LinkMultiple; use Espo\Core\Field\LinkParent; @@ -105,7 +106,8 @@ class Service private SelectBuilderFactory $selectBuilderFactory, private UserAclManagerProvider $userAclManagerProvider, private RecordServiceContainer $recordServiceContainer, - private SystemUser $systemUser + private SystemUser $systemUser, + private AsssignmentHelper $assignmentHelper, ) {} private function getStatusField(string $entityType): ?string @@ -593,27 +595,30 @@ class Service $data = []; - if ($entity->get('assignedUserId')) { - $this->loadAssignedUserName($entity); - - $data['assignedUserId'] = $entity->get('assignedUserId'); - $data['assignedUserName'] = $entity->get('assignedUserName'); - } else if ( + if ( $entity instanceof CoreEntity && - $entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS) && - $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS) !== [] && // Exclude for Email as the assignedUsers serves not for direct assignment. - $entity->getEntityType() !== Email::ENTITY_TYPE + $entity->getEntityType() !== Email::ENTITY_TYPE && + $this->assignmentHelper->hasAssignedUsersField($entity->getEntityType()) && + $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS) !== [] ) { /** @var LinkMultiple $users */ $users = $entity->getValueObject(self::FIELD_ASSIGNED_USERS); - $data['assignedUsers'] = array_map(function ($it) { + $data[Note::DATA_ATTR_ASSIGNED_USERS] = array_map(function ($it) { return [ Attribute::ID => $it->getId(), - 'name' => $it->getName(), + Field::NAME => $it->getName(), ]; }, $users->getList()); + } else if ( + $this->assignmentHelper->hasAssignedUserField($entity->getEntityType()) && + $entity->get(Field::ASSIGNED_USER . 'Id') + ) { + $this->loadAssignedUserName($entity); + + $data[Note::DATA_ATTR_ASSIGNED_USER_ID] = $entity->get(Field::ASSIGNED_USER . 'Id'); + $data[Note::DATA_ATTR_ASSIGNED_USER_NAME] = $entity->get(Field::ASSIGNED_USER . 'Name'); } $field = $this->getStatusField($entityType); @@ -622,12 +627,12 @@ class Service $value = $entity->get($field); if ($value) { - $data['statusValue'] = $value; - $data['statusField'] = $field; + $data[Note::DATA_ATTR_STATUS_VALUE] = $value; + $data[Note::DATA_ATTR_STATUS_FIELD] = $field; } } - $note->set('data', (object) $data); + $note->setData($data); $noteOptions = []; @@ -915,8 +920,8 @@ class Service $note->setParent(LinkParent::fromEntity($entity)); $note->setData([ - 'fields' => $updatedFieldList, - 'attributes' => [ + Note::DATA_ATTR_FIELDS => $updatedFieldList, + Note::DATA_ATTR_ATTRIBUTES => [ 'was' => (object) $was, 'became' => (object) $became, ], @@ -1160,9 +1165,10 @@ class Service { if ( $entity instanceof CoreEntity && - $entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS) && + $entity->getEntityType() !== Email::ENTITY_TYPE && // Exclude for Email as the assignedUsers serves not for direct assignment. - $entity->getEntityType() !== Email::ENTITY_TYPE + $this->assignmentHelper->hasAssignedUsersField($entity->getEntityType()) && + $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS) !== [] ) { $data = []; @@ -1179,17 +1185,17 @@ class Service $removedIds = array_values(array_diff($prevIds, $newIds)); $names = array_merge($prevNames, $newNames); - $data['addedAssignedUsers'] = array_map(function ($id) use ($names) { + $data[Note::DATA_ATTR_ADDED_ASSIGNED_USERS] = array_map(function ($id) use ($names) { return [ - 'id' => $id, - 'name' => $names[$id] ?? null, + Attribute::ID => $id, + Field::NAME => $names[$id] ?? null, ]; }, $addedIds); - $data['removedAssignedUsers'] = array_map(function ($id) use ($names) { + $data[Note::DATA_ATTR_REMOVED_ASSIGNED_USERS] = array_map(function ($id) use ($names) { return [ - 'id' => $id, - 'name' => $names[$id] ?? null, + Attribute::ID => $id, + Field::NAME => $names[$id] ?? null, ]; }, $removedIds); @@ -1198,18 +1204,22 @@ class Service return; } - if ($entity->get('assignedUserId')) { + if ( + $this->assignmentHelper->hasAssignedUserField($entity->getEntityType()) && + $entity->get(Field::ASSIGNED_USER . 'Id') + ) { $this->loadAssignedUserName($entity); $note->setData([ - 'assignedUserId' => $entity->get('assignedUserId'), - 'assignedUserName' => $entity->get('assignedUserName'), + Note::DATA_ATTR_ASSIGNED_USER_ID => $entity->get(Field::ASSIGNED_USER . 'Id'), + Note::DATA_ATTR_ASSIGNED_USER_NAME => $entity->get(Field::ASSIGNED_USER . 'Name'), ]); return; } - $note->setData(['assignedUserId' => null]); + + $note->setData([Note::DATA_ATTR_ASSIGNED_USER_ID => null]); } /** diff --git a/client/res/templates/notification/fields/container.tpl b/client/res/templates/notification/fields/container.tpl index 7609ec9c69..f62cf377de 100644 --- a/client/res/templates/notification/fields/container.tpl +++ b/client/res/templates/notification/fields/container.tpl @@ -1,6 +1,6 @@
{{{notification}}}
{{#if hasGrouped}} -
+
{{#if isGroupExpanded}} {{{groupedList}}} {{else}} diff --git a/client/src/collections/note.js b/client/src/collections/note.js index fa4ec52a6c..572fafad13 100644 --- a/client/src/collections/note.js +++ b/client/src/collections/note.js @@ -32,6 +32,9 @@ import Collection from 'collection'; class NoteCollection extends Collection { + /** + * @type {boolean} + */ paginationByNumber = false /** diff --git a/client/src/collections/notification.js b/client/src/collections/notification.js new file mode 100644 index 0000000000..831582c915 --- /dev/null +++ b/client/src/collections/notification.js @@ -0,0 +1,34 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2026 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 . + * + * 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. + ************************************************************************/ + +import NoteCollection from 'collections/note'; + +export default class NotificationCollection extends NoteCollection { + + paginationByNumber = true +} diff --git a/client/src/models/notification.js b/client/src/models/notification.js new file mode 100644 index 0000000000..68100c5117 --- /dev/null +++ b/client/src/models/notification.js @@ -0,0 +1,46 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2026 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 . + * + * 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. + ************************************************************************/ + +import Model from 'model'; + +export default class Notification extends Model { + + /** + * @inheritDoc + */ + constructor(attributes, options) { + super(attributes, options); + + if ( + !options.url && + this.attributes.groupType + ) { + this.urlRoot = 'Notification/group'; + } + } +} diff --git a/client/src/views/notification/badge.js b/client/src/views/notification/badge.js index d0fdf930ef..56a72dd613 100644 --- a/client/src/views/notification/badge.js +++ b/client/src/views/notification/badge.js @@ -568,6 +568,7 @@ class NotificationBadgeView extends View { }); this.listenTo(view, 'collection-fetched', () => { + console.log(1); this.checkUpdates(); this.broadcastNotificationsRead(); }); diff --git a/client/src/views/notification/fields/container.js b/client/src/views/notification/fields/container.js index 198afbd405..9010f63451 100644 --- a/client/src/views/notification/fields/container.js +++ b/client/src/views/notification/fields/container.js @@ -28,6 +28,7 @@ import BaseFieldView from 'views/fields/base'; import NotificationListRecordView from 'views/notification/record/list'; +import NotificationPanelView from 'views/notification/panel'; class NotificationContainerFieldView extends BaseFieldView { @@ -59,26 +60,32 @@ class NotificationContainerFieldView extends BaseFieldView { isGroupExpanded = false data() { + const count = this.model.attributes.groupedCount ?? 0; + return { - hasGrouped: (this.model.attributes.groupedCount ?? 0) > 1, + hasGrouped: count > 1 || count < 0, isGroupExpanded: this.isGroupExpanded, }; } setup() { - switch (this.model.attributes.type) { - case 'Note': - this.processNote(this.model.attributes.noteData); + if (this.model.attributes.groupType) { + this.wait(this.processGroup()); + } else { + switch (this.model.attributes.type) { + case 'Note': + this.processNote(this.model.attributes.noteData); - break; + break; - case 'MentionInPost': - this.processMentionInPost(this.model.attributes.noteData); + case 'MentionInPost': + this.processMentionInPost(this.model.attributes.noteData); - break; + break; - default: - this.process(); + default: + this.process(); + } } this.addActionHandler('showGrouped', () => this.showGrouped()); @@ -111,6 +118,32 @@ class NotificationContainerFieldView extends BaseFieldView { }); } + /** + * @private + */ + async processGroup() { + const groupType = this.model.attributes.groupType; + + let viewName; + + if (groupType === 'Note') { + viewName = 'views/notification/items/group-note'; + } else if (groupType === 'EmailReceived') { + viewName = 'views/notification/items/group-email-received'; + } + + if (!viewName) { + return; + } + + const parentSelector = this.options.containerSelector ?? this.getSelector(); + + await this.createView('notification', viewName, { + model: this.model, + fullSelector: `${parentSelector} li[data-id="${this.model.id}"]`, + }); + } + /** * @private * @param {Record} data @@ -140,6 +173,7 @@ class NotificationContainerFieldView extends BaseFieldView { fullSelector: `${parentSelector} li[data-id="${this.model.id}"] .cell[data-name="data"]`, onlyContent: true, isNotification: true, + isInGroup: this.options.isInGroup ?? false, }); this.wait(false); @@ -183,7 +217,14 @@ class NotificationContainerFieldView extends BaseFieldView { async showGrouped() { const collection = await this.getCollectionFactory().create('Notification'); - collection.url = `Notification/${this.model.id}/group`; + if (this.model.attributes.groupType) { + collection.url = `Notification/group?type=${this.model.attributes.groupType}&id=` + + this.model.id; + + collection.maxSize = this.getConfig().get('recordsPerPageSmall'); + } else { + collection.url = `Notification/${this.model.id}/group`; + } const button = this.element.querySelector('a[data-action="showGrouped"]'); @@ -221,6 +262,9 @@ class NotificationContainerFieldView extends BaseFieldView { { name: 'data', view: 'views/notification/fields/container', + options: { + isInGroup: true, + }, }, ], ], @@ -235,6 +279,20 @@ class NotificationContainerFieldView extends BaseFieldView { await this.assignView('groupedList', view); await this.reRender(); + + let viewPointer = this; + + while (true) { + viewPointer = viewPointer.getParentView(); + + if (!viewPointer || viewPointer instanceof NotificationPanelView) { + break; + } + } + + if (viewPointer instanceof NotificationPanelView) { + viewPointer.trigger('collection-fetched'); + } } } diff --git a/client/src/views/notification/items/assign.js b/client/src/views/notification/items/assign.js index 391c679be8..d855c60ec7 100644 --- a/client/src/views/notification/items/assign.js +++ b/client/src/views/notification/items/assign.js @@ -41,12 +41,7 @@ class AssignNotificationItemView extends BaseNotificationItemView { this.messageData['entityType'] = this.translateEntityType(data.entityType); - this.messageData['entity'] = - $('') - .attr('href', '#' + data.entityType + '/view/' + data.entityId) - .attr('data-id', data.entityId) - .attr('data-scope', data.entityType) - .text(data.entityName); + this.messageData['entity'] = 'field:related'; this.messageData['user'] = $('') diff --git a/client/src/views/notification/items/group-email-received.js b/client/src/views/notification/items/group-email-received.js new file mode 100644 index 0000000000..2cf03553ae --- /dev/null +++ b/client/src/views/notification/items/group-email-received.js @@ -0,0 +1,72 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2026 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 . + * + * 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. + ************************************************************************/ + + +import BaseNotificationItemView from 'views/notification/items/base'; + +// noinspection JSUnusedGlobalSymbols +export default class GroupNoteNotificationItemView extends BaseNotificationItemView { + + // language=Handlebars + templateContent = ` +
+
+ +
+
+ {{{message}}} +
+
+ +
+ {{{createdAt}}} +
+ ` + + data() { + return { + ...super.data(), + }; + } + + setup() { + const newCount = this.model.attributes.groupedUnreadCount ?? 0; + + this.messageData['number'] = newCount.toString(); + + this.messageName = 'groupEmailsReceivedNew'; + + if (newCount === 0) { + this.messageName = 'groupEmailsReceived'; + } + + this.createMessage(); + } +} diff --git a/client/src/views/notification/items/group-note.js b/client/src/views/notification/items/group-note.js new file mode 100644 index 0000000000..9402d38d61 --- /dev/null +++ b/client/src/views/notification/items/group-note.js @@ -0,0 +1,102 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2026 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 . + * + * 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. + ************************************************************************/ + +import BaseNotificationItemView from 'views/notification/items/base'; + +// noinspection JSUnusedGlobalSymbols +export default class GroupNoteNotificationItemView extends BaseNotificationItemView { + + // language=Handlebars + templateContent = ` +
+ {{#if iconClass}} +
+ +
+ {{/if}} +
+ {{{message}}} +
+
+ {{~#if isFeatured~}} +
+ {{translate 'Assigned'}} +
+ {{~/if~}} +
+ {{{createdAt}}} +
+ ` + + data() { + const relatedParentType = this.model.attributes.relatedParentType + const iconClass = this.getMetadata().get(`clientDefs.${relatedParentType}.iconClass`); + const color = this.getMetadata().get(`clientDefs.${relatedParentType}.color`); + + return { + ...super.data(), + relatedParentId: this.model.attributes.relatedParentId, + relatedParentType: this.model.attributes.relatedParentType, + isFeatured: this.model.attributes.isFeatured, + iconClass, + color, + }; + } + + setup() { + const relatedParentType = this.model.attributes.relatedParentType; + + if (relatedParentType) { + this.messageData['entityType'] = this.translateEntityType(relatedParentType); + } + + this.messageData['entity'] = 'field:relatedParent'; + + const newCount = this.model.attributes.groupedUnreadCount ?? 0; + + this.messageData['number'] = newCount.toString(); + + this.messageName = newCount > 1 ? 'groupUpdatesMultiple' : 'groupUpdatesOne'; + + if (newCount === 0) { + this.messageName = 'groupUpdates'; + } + + this.createMessage(); + } +} diff --git a/client/src/views/notification/items/user-reaction.js b/client/src/views/notification/items/user-reaction.js index 9557ec2b7a..89f13bd75e 100644 --- a/client/src/views/notification/items/user-reaction.js +++ b/client/src/views/notification/items/user-reaction.js @@ -102,14 +102,8 @@ export default class UserReactionNotificationItemView extends BaseNotificationIt this.messageData['user'] = userElement; if (relatedParentId && relatedParentType) { - const relatedParentElement = document.createElement('a'); - relatedParentElement.href = `#${relatedParentType}/view/${relatedParentId}`; - relatedParentElement.dataset.id = relatedParentId; - relatedParentElement.dataset.scope = relatedParentType; - relatedParentElement.textContent = data.entityName || relatedParentType; - this.messageData['entityType'] = this.translateEntityType(relatedParentType); - this.messageData['entity'] = relatedParentElement; + this.messageData['entity'] = 'field:relatedParent'; this.messageName = 'userPostInParentReaction'; } diff --git a/client/src/views/notification/record/list.js b/client/src/views/notification/record/list.js index ce6d7ed4d9..cc3d8fc096 100644 --- a/client/src/views/notification/record/list.js +++ b/client/src/views/notification/record/list.js @@ -108,8 +108,21 @@ class NotificationListRecordView extends ListExpandedRecordView { * @return {Promise} */ showNewRecords() { + if (this.isGroupingEnabled()) { + return this.collection.fetch(); + } + return this.collection.fetchNew(); } + + /** + * @todo Preferences? + * @private + * @return {boolean} + */ + isGroupingEnabled() { + return true; + } } export default NotificationListRecordView; diff --git a/client/src/views/stream/note.js b/client/src/views/stream/note.js index 085c59d3d4..b0b4f29d75 100644 --- a/client/src/views/stream/note.js +++ b/client/src/views/stream/note.js @@ -243,6 +243,10 @@ class NoteStreamView extends View { id = this.model.attributes.parentId; } + if (this.options.isInGroup) { + return null; + } + if (this.isThis && this.parentModel && scope === this.parentModel.entityType) { return null; } diff --git a/client/src/views/stream/notes/mention-in-post.js b/client/src/views/stream/notes/mention-in-post.js index 840835790e..0ca748f461 100644 --- a/client/src/views/stream/notes/mention-in-post.js +++ b/client/src/views/stream/notes/mention-in-post.js @@ -58,7 +58,10 @@ class MentionInPostNoteStreamView extends NoteStreamView { this.messageName = 'mentionInPostTarget'; } - if (!this.isUserStream || this.options.userId !== this.getUser().id) { + if ( + (!this.options.isNotification) && + (!this.isUserStream || this.options.userId !== this.getUser().id) + ) { this.createMessage(); return; diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index a9a1eee884..d65b5827c9 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -1421,13 +1421,13 @@ section { .notification-grouped > .list > .list-group { > li { &:first-child { - border-top-left-radius: var(--border-radius); - border-top-right-radius: var(--border-radius); + //border-top-left-radius: var(--border-radius); + //border-top-right-radius: var(--border-radius); } &:last-child { - border-bottom-left-radius: var(--border-radius); - border-bottom-right-radius: var(--border-radius); + //border-bottom-left-radius: var(--border-radius); + //border-bottom-right-radius: var(--border-radius); } } @@ -2147,7 +2147,9 @@ td > span.color-icon { top: var(--minus-1px); } - padding-left: var(--29px); + &:not(:first-child) { + padding-left: var(--29px); + } > .icon:first-child { min-width: var(--16px); @@ -2236,6 +2238,11 @@ td > span.color-icon { .stream-head-container { margin-bottom: var(--4px); + + > .stream-head-left-icon-container { + width: var(--26px); + text-align: center; + } } .stream-head-container .internal-badge {