diff --git a/.idea/jsonSchemas.xml b/.idea/jsonSchemas.xml index af62da91e2..74188ba28f 100644 --- a/.idea/jsonSchemas.xml +++ b/.idea/jsonSchemas.xml @@ -1006,6 +1006,25 @@ + + + + + + + diff --git a/.vscode/settings.json b/.vscode/settings.json index 2295b6c3ea..603397e5ac 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -394,6 +394,12 @@ ], "url": "./schema/metadata/app/portalContainerServices.json" }, + { + "fileMatch": [ + "*/Resources/metadata/app/reactions.json" + ], + "url": "./schema/metadata/app/reactions.json" + }, { "fileMatch": [ "*/Resources/metadata/app/rebuild.json" diff --git a/application/Espo/Classes/FieldProcessing/Note/AdditionalFieldsLoader.php b/application/Espo/Classes/FieldProcessing/Note/AdditionalFieldsLoader.php index 2d8d5f117d..5981ec745a 100644 --- a/application/Espo/Classes/FieldProcessing/Note/AdditionalFieldsLoader.php +++ b/application/Espo/Classes/FieldProcessing/Note/AdditionalFieldsLoader.php @@ -33,14 +33,21 @@ use Espo\Core\FieldProcessing\Loader; use Espo\Core\FieldProcessing\Loader\Params; use Espo\Entities\Note; use Espo\ORM\Entity; +use Espo\Tools\Stream\MassNotePreparator; /** * @implements Loader */ class AdditionalFieldsLoader implements Loader { + public function __construct( + private MassNotePreparator $massNotePreparator, + ) {} + public function process(Entity $entity, Params $params): void { $entity->loadAdditionalFields(); + + $this->massNotePreparator->prepare([$entity]); } } diff --git a/application/Espo/Classes/FieldValidators/Settings/AvailableReactions/Valid.php b/application/Espo/Classes/FieldValidators/Settings/AvailableReactions/Valid.php new file mode 100644 index 0000000000..ebde19a82e --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Settings/AvailableReactions/Valid.php @@ -0,0 +1,66 @@ +. + * + * 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\Classes\FieldValidators\Settings\AvailableReactions; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Settings; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function __construct(private Metadata $metadata) + {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->get($field); + + if (!is_array($value)) { + return null; + } + + /** @var string[] $allowedList */ + $allowedList = array_map(fn ($it) => $it['type'], $this->metadata->get("app.reactions.list", [])); + + foreach ($value as $it) { + if (!in_array($it, $allowedList)) { + return Failure::create(); + } + } + + return null; + } +} diff --git a/application/Espo/Controllers/Stream.php b/application/Espo/Controllers/Stream.php index 08d17a3128..7a42eb1d88 100644 --- a/application/Espo/Controllers/Stream.php +++ b/application/Espo/Controllers/Stream.php @@ -34,6 +34,7 @@ use Espo\Core\Exceptions\BadRequest; use Espo\Core\Api\Request; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\NotFound; +use Espo\Core\Field\DateTime; use Espo\Core\Record\SearchParamsFetcher; use Espo\Core\Select\SearchParams; @@ -42,6 +43,7 @@ use Espo\Entities\User as UserEntity; use Espo\Tools\Stream\RecordService; use Espo\Tools\Stream\UserRecordService; +use Espo\Tools\UserReaction\ReactionStreamService; use stdClass; class Stream @@ -51,7 +53,8 @@ class Stream public function __construct( private RecordService $service, private UserRecordService $userRecordService, - private SearchParamsFetcher $searchParamsFetcher + private SearchParamsFetcher $searchParamsFetcher, + private ReactionStreamService $reactionStreamService, ) {} /** @@ -73,9 +76,13 @@ class Stream if ($scope === UserEntity::ENTITY_TYPE) { $collection = $this->userRecordService->find($id, $searchParams); + $reactionsCheckDate = DateTime::createNow(); + return (object) [ 'total' => $collection->getTotal(), 'list' => $collection->getValueMapList(), + 'reactionsCheckDate' => $reactionsCheckDate->toString(), + 'updatedReactions' => $this->getReactionUpdates($request, $id), ]; } @@ -195,4 +202,22 @@ class Stream return $searchParams; } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + * @return stdClass[] + */ + private function getReactionUpdates(Request $request, ?string $id): array + { + $reactionsAfter = $request->getQueryParam('reactionsAfter'); + $noteIds = explode(',', $request->getQueryParam('reactionsCheckNoteIds') ?? ''); + + if (!$reactionsAfter || !$noteIds) { + return []; + } + + return $this->reactionStreamService->getReactionUpdates(DateTime::fromString($reactionsAfter), $noteIds, $id); + } } diff --git a/application/Espo/Core/Upgrades/Migrations/V9_0/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V9_0/AfterUpgrade.php new file mode 100644 index 0000000000..203938d5b1 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V9_0/AfterUpgrade.php @@ -0,0 +1,69 @@ +. + * + * 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_0; + +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 + { + $users = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->sth() + ->where([ + 'isActive' => true, + '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('reactionNotifications', true); + $this->entityManager->saveEntity($preferences); + } + } +} diff --git a/application/Espo/Entities/Note.php b/application/Espo/Entities/Note.php index 9463cfb512..7323d9b015 100644 --- a/application/Espo/Entities/Note.php +++ b/application/Espo/Entities/Note.php @@ -35,6 +35,7 @@ use Espo\Core\ORM\Entity; use Espo\Core\Field\DateTime; use Espo\ORM\Collection; +use Espo\ORM\Entity as OrmEntity; use RuntimeException; use stdClass; @@ -360,6 +361,11 @@ class Note extends Entity return $this; } + public function getParent(): ?OrmEntity + { + return $this->relations->getOne('parent'); + } + /** * @return iterable */ diff --git a/application/Espo/Entities/Notification.php b/application/Espo/Entities/Notification.php index e1a42b5e76..3aa9ef41b6 100644 --- a/application/Espo/Entities/Notification.php +++ b/application/Espo/Entities/Notification.php @@ -44,6 +44,7 @@ class Notification extends Entity public const TYPE_NOTE = 'Note'; public const TYPE_MENTION_IN_POST = 'MentionInPost'; public const TYPE_MESSAGE = 'Message'; + public const TYPE_USER_REACTION = 'UserReaction'; public const TYPE_SYSTEM = 'System'; public function getType(): ?string @@ -93,9 +94,28 @@ class Notification extends Entity return $this->getValueObject('related'); } - public function setRelated(?LinkParent $related): self + public function setRelated(LinkParent|Entity|null $related): self { - $this->setValueObject('related', $related); + if ($related instanceof LinkParent) { + $this->setValueObject('related', $related); + + return $this; + } + + $this->relations->set('related', $related); + + return $this; + } + + public function setRelatedParent(LinkParent|Entity|null $relatedParent): self + { + if ($relatedParent instanceof LinkParent) { + $this->setValueObject('relatedParent', $relatedParent); + + return $this; + } + + $this->relations->set('relatedParent', $relatedParent); return $this; } diff --git a/application/Espo/Entities/UserReaction.php b/application/Espo/Entities/UserReaction.php new file mode 100644 index 0000000000..44a64c8b21 --- /dev/null +++ b/application/Espo/Entities/UserReaction.php @@ -0,0 +1,70 @@ +. + * + * 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\Entities; + +use Espo\Core\Field\LinkParent; +use Espo\Core\ORM\Entity; + +class UserReaction extends Entity +{ + const ENTITY_TYPE = 'UserReaction'; + + public function getType(): string + { + return $this->get('type'); + } + + public function getParent(): LinkParent + { + /** @var LinkParent */ + return $this->getValueObject('parent'); + } + + public function setType(string $type): self + { + $this->set('type', $type); + + return $this; + } + + public function setParent(Note $note): self + { + $this->relations->set('parent', $note); + + return $this; + } + + public function setUser(User $user): self + { + $this->relations->set('user', $user); + + return $this; + } +} diff --git a/application/Espo/Resources/defaults/config.php b/application/Espo/Resources/defaults/config.php index 5d1bb31c03..56aadb97a1 100644 --- a/application/Espo/Resources/defaults/config.php +++ b/application/Espo/Resources/defaults/config.php @@ -308,5 +308,7 @@ return [ 'authIpAddressWhitelist' => [], 'authIpAddressCheckExcludedUsersIds' => [], 'authIpAddressCheckExcludedUsersNames' => (object) [], + 'availableReactions' => ['Like'], + 'streamReactionsCheckMaxSize' => 50, 'isInstalled' => false, ]; diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index c666f396e3..9ecd3e2716 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -311,7 +311,10 @@ "Copy to Clipboard": "Copy to Clipboard", "Copied to clipboard": "Copied to clipboard", "Audit Log": "Audit Log", - "View Audit Log": "View Audit Log" + "View Audit Log": "View Audit Log", + "Reacted": "Reacted", + "Reaction Removed": "Reaction Removed", + "Reactions": "Reactions" }, "messages": { "pleaseWait": "Please wait...", @@ -517,7 +520,9 @@ "assign": "{entityType} {entity} has been assigned to you", "emailReceived": "Email received from {from}", "entityRemoved": "{user} removed {entityType} {entity}", - "emailInbox": "{user} added email {entity} to your inbox" + "emailInbox": "{user} added email {entity} to your inbox", + "userPostReaction": "{user} reacted to your {post}", + "userPostInParentReaction": "{user} reacted to your {post} in {entityType} {entity}" }, "streamMessages": { "post": "{user} posted on {entityType} {entity}", @@ -984,5 +989,15 @@ }, "strings": { "yesterdayShort": "Yest" + }, + "reactions": { + "Smile": "Smile", + "Surprise": "Surprise", + "Laugh": "Laugh", + "Meh": "Meh", + "Sad": "Sad", + "Love": "Love", + "Like": "Like", + "Dislike": "Dislike" } } diff --git a/application/Espo/Resources/i18n/en_US/Preferences.json b/application/Espo/Resources/i18n/en_US/Preferences.json index 8b027a4d1e..b972e24bb6 100644 --- a/application/Espo/Resources/i18n/en_US/Preferences.json +++ b/application/Espo/Resources/i18n/en_US/Preferences.json @@ -15,6 +15,7 @@ "receiveStreamEmailNotifications": "Email notifications about posts and status updates", "assignmentNotificationsIgnoreEntityTypeList": "In-app assignment notifications", "assignmentEmailNotificationsIgnoreEntityTypeList": "Email assignment notifications", + "reactionNotifications": "In-app notifications about reactions", "autoFollowEntityTypeList": "Global Auto-Follow", "signature": "Email Signature", "dashboardTabList": "Tab List", diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index 673246d20c..68f4a1f345 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -172,7 +172,8 @@ "quickSearchFullTextAppendWildcard": "Append wildcard in quick search", "authIpAddressCheck": "Restrict access by IP address", "authIpAddressWhitelist": "IP Address Whitelist", - "authIpAddressCheckExcludedUsers": "Users excluded from check" + "authIpAddressCheckExcludedUsers": "Users excluded from check", + "availableReactions": "Available Reactions" }, "options": { "authenticationMethod": { diff --git a/application/Espo/Resources/layouts/Note/defaultSidePanel.json b/application/Espo/Resources/layouts/Note/defaultSidePanel.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/application/Espo/Resources/layouts/Note/defaultSidePanel.json @@ -0,0 +1 @@ +[] diff --git a/application/Espo/Resources/layouts/Preferences/detail.json b/application/Espo/Resources/layouts/Preferences/detail.json index 2274c421bb..de50339571 100644 --- a/application/Espo/Resources/layouts/Preferences/detail.json +++ b/application/Espo/Resources/layouts/Preferences/detail.json @@ -139,6 +139,10 @@ [ {"name": "assignmentNotificationsIgnoreEntityTypeList"}, {"name": "assignmentEmailNotificationsIgnoreEntityTypeList"} + ], + [ + {"name": "reactionNotifications"}, + false ] ] } diff --git a/application/Espo/Resources/layouts/Preferences/detailPortal.json b/application/Espo/Resources/layouts/Preferences/detailPortal.json index 0836aa55d1..0d31f28a1d 100644 --- a/application/Espo/Resources/layouts/Preferences/detailPortal.json +++ b/application/Espo/Resources/layouts/Preferences/detailPortal.json @@ -27,7 +27,11 @@ "label": "Notifications", "name": "notifications", "rows": [ - [{"name": "receiveStreamEmailNotifications"}, false] + [{"name": "receiveStreamEmailNotifications"}, false], + [ + {"name": "reactionNotifications"}, + false + ] ] } ] diff --git a/application/Espo/Resources/layouts/Settings/settings.json b/application/Espo/Resources/layouts/Settings/settings.json index 96daa33f03..0cb008b291 100644 --- a/application/Espo/Resources/layouts/Settings/settings.json +++ b/application/Espo/Resources/layouts/Settings/settings.json @@ -46,9 +46,9 @@ "tabBreak": true, "tabLabel": "$label:General", "rows": [ - [{"name": "followCreatedEntities"}, {"name": "emailAddressIsOptedOutByDefault"}], - [{"name": "aclAllowDeleteCreated"}, {"name": "cleanupDeletedRecords"}], - [{"name": "exportDisabled"}, {"name": "b2cMode"}], + [{"name": "cleanupDeletedRecords"}, {"name": "emailAddressIsOptedOutByDefault"}], + [{"name": "aclAllowDeleteCreated"}, {"name": "b2cMode"}], + [{"name": "exportDisabled"}, false], [{"name": "pdfEngine"}, false] ] @@ -80,5 +80,11 @@ "rows": [ [{"name": "attachmentUploadMaxSize"}, {"name": "attachmentUploadChunkSize"}] ] + }, + { + "label": "Stream", + "rows": [ + [{"name": "availableReactions"}, {"name": "followCreatedEntities"}] + ] } ] diff --git a/application/Espo/Resources/metadata/app/config.json b/application/Espo/Resources/metadata/app/config.json index fcdeaf6c3d..7a636acb99 100644 --- a/application/Espo/Resources/metadata/app/config.json +++ b/application/Espo/Resources/metadata/app/config.json @@ -153,6 +153,9 @@ }, "authIpAddressCheckExcludedUsers": { "level": "superAdmin" + }, + "availableReactions": { + "level": "global" } } } diff --git a/application/Espo/Resources/metadata/app/reactions.json b/application/Espo/Resources/metadata/app/reactions.json new file mode 100644 index 0000000000..25fe4698a4 --- /dev/null +++ b/application/Espo/Resources/metadata/app/reactions.json @@ -0,0 +1,36 @@ +{ + "list": [ + { + "type": "Smile", + "iconClass": "far fa-face-smile" + }, + { + "type": "Surprise", + "iconClass": "far fa-face-surprise" + }, + { + "type": "Laugh", + "iconClass": "far fa-face-laugh" + }, + { + "type": "Meh", + "iconClass": "far fa-face-meh" + }, + { + "type": "Sad", + "iconClass": "far fa-face-frown" + }, + { + "type": "Love", + "iconClass": "far fa-heart" + }, + { + "type": "Like", + "iconClass": "far fa-thumbs-up" + }, + { + "type": "Dislike", + "iconClass": "far fa-thumbs-down" + } + ] +} diff --git a/application/Espo/Resources/metadata/clientDefs/Note.json b/application/Espo/Resources/metadata/clientDefs/Note.json index e66d2d047d..d98e3da7bd 100644 --- a/application/Espo/Resources/metadata/clientDefs/Note.json +++ b/application/Espo/Resources/metadata/clientDefs/Note.json @@ -11,5 +11,8 @@ }, "itemViews": { "Post": "views/stream/notes/post" + }, + "viewSetupHandlers": { + "record/detail": ["handlers/note/record-detail-setup"] } } diff --git a/application/Espo/Resources/metadata/entityDefs/Note.json b/application/Espo/Resources/metadata/entityDefs/Note.json index 7a979eb032..f71ad88210 100644 --- a/application/Espo/Resources/metadata/entityDefs/Note.json +++ b/application/Espo/Resources/metadata/entityDefs/Note.json @@ -128,6 +128,20 @@ "customizationDisabled": true, "readOnly": true }, + "reactionCounts": { + "type": "jsonObject", + "notStorable": true, + "readOnly": true, + "customizationDisabled": true, + "utility": true + }, + "myReactions": { + "type": "jsonArray", + "notStorable": true, + "readOnly": true, + "customizationDisabled": true, + "utility": true + }, "createdAt": { "type": "datetime", "readOnly": true, diff --git a/application/Espo/Resources/metadata/entityDefs/Notification.json b/application/Espo/Resources/metadata/entityDefs/Notification.json index 541acb3508..3bc6efb8e2 100644 --- a/application/Espo/Resources/metadata/entityDefs/Notification.json +++ b/application/Espo/Resources/metadata/entityDefs/Notification.json @@ -39,9 +39,18 @@ "relatedParent": { "type": "linkParent", "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" } }, "links": { + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, "user": { "type": "belongsTo", "entity": "User" diff --git a/application/Espo/Resources/metadata/entityDefs/Preferences.json b/application/Espo/Resources/metadata/entityDefs/Preferences.json index 7d288bf642..9191faf8cf 100644 --- a/application/Espo/Resources/metadata/entityDefs/Preferences.json +++ b/application/Espo/Resources/metadata/entityDefs/Preferences.json @@ -107,6 +107,10 @@ "translation": "Global.scopeNamesPlural", "view": "views/preferences/fields/assignment-email-notifications-ignore-entity-type-list" }, + "reactionNotifications": { + "type": "bool", + "default": true + }, "autoFollowEntityTypeList": { "type": "multiEnum", "view": "views/preferences/fields/auto-follow-entity-type-list", diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index fa119c8be9..4b3713f805 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -918,6 +918,14 @@ "type": "linkMultiple", "entity": "User", "tooltip": true + }, + "availableReactions": { + "type": "array", + "maxCount": 9, + "view": "views/settings/fields/available-reactions", + "validatorClassNameList": [ + "Espo\\Classes\\FieldValidators\\Settings\\AvailableReactions\\Valid" + ] } } } diff --git a/application/Espo/Resources/metadata/entityDefs/UserReaction.json b/application/Espo/Resources/metadata/entityDefs/UserReaction.json new file mode 100644 index 0000000000..12c08170e6 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/UserReaction.json @@ -0,0 +1,39 @@ +{ + "fields": { + "type": { + "type": "varchar", + "maxLength": 10 + }, + "user": { + "type": "link" + }, + "parent": { + "type": "linkParent" + }, + "createdAt": { + "type": "datetime" + } + }, + "links": { + "user": { + "type": "belongsTo", + "entity": "User" + }, + "parent": { + "type": "belongsToParent", + "entityList": ["Note"] + } + }, + "indexes": { + "parentUserType": { + "unique": true, + "columns": [ + "parentId", + "parentType", + "userId", + "type" + ] + } + }, + "noDeletedAttribute": true +} diff --git a/application/Espo/Resources/metadata/scopes/UserReaction.json b/application/Espo/Resources/metadata/scopes/UserReaction.json new file mode 100644 index 0000000000..39c96e7ac2 --- /dev/null +++ b/application/Espo/Resources/metadata/scopes/UserReaction.json @@ -0,0 +1,3 @@ +{ + "entity": true +} diff --git a/application/Espo/Resources/routes.json b/application/Espo/Resources/routes.json index 74512f483a..02b8bd525c 100644 --- a/application/Espo/Resources/routes.json +++ b/application/Espo/Resources/routes.json @@ -324,6 +324,21 @@ "method": "post", "actionClassName": "Espo\\Tools\\Attachment\\Api\\PostCopy" }, + { + "route": "/Note/:id/myReactions/:type", + "method": "post", + "actionClassName": "Espo\\Tools\\Stream\\Api\\PostMyReactions" + }, + { + "route": "/Note/:id/myReactions/:type", + "method": "delete", + "actionClassName": "Espo\\Tools\\Stream\\Api\\DeleteMyReactions" + }, + { + "route": "/Note/:id/reactors/:type", + "method": "get", + "actionClassName": "Espo\\Tools\\Stream\\Api\\GetNoteReactors" + }, { "route": "/EmailTemplate/:id/prepare", "method": "post", diff --git a/application/Espo/Tools/Stream/Api/DeleteMyReactions.php b/application/Espo/Tools/Stream/Api/DeleteMyReactions.php new file mode 100644 index 0000000000..7d8bc73b3d --- /dev/null +++ b/application/Espo/Tools/Stream/Api/DeleteMyReactions.php @@ -0,0 +1,62 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Tools/Stream/Api/GetNoteReactors.php b/application/Espo/Tools/Stream/Api/GetNoteReactors.php new file mode 100644 index 0000000000..aced7d436f --- /dev/null +++ b/application/Espo/Tools/Stream/Api/GetNoteReactors.php @@ -0,0 +1,106 @@ +. + * + * 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, + ]); + } +} diff --git a/application/Espo/Tools/Stream/Api/PostMyReactions.php b/application/Espo/Tools/Stream/Api/PostMyReactions.php new file mode 100644 index 0000000000..c23651c9f0 --- /dev/null +++ b/application/Espo/Tools/Stream/Api/PostMyReactions.php @@ -0,0 +1,62 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Tools/Stream/GlobalRecordService.php b/application/Espo/Tools/Stream/GlobalRecordService.php index ef0e0a4fec..18773c9606 100644 --- a/application/Espo/Tools/Stream/GlobalRecordService.php +++ b/application/Espo/Tools/Stream/GlobalRecordService.php @@ -58,7 +58,8 @@ class GlobalRecordService private EntityManager $entityManager, private QueryHelper $queryHelper, private NoteAccessControl $noteAccessControl, - private NoteHelper $noteHelper + private NoteHelper $noteHelper, + private MassNotePreparator $massNotePreparator, ) {} /** @@ -139,6 +140,8 @@ class GlobalRecordService $this->noteHelper->prepare($note); } + $this->massNotePreparator->prepare($collection); + return RecordCollection::createNoCount($collection, $maxSize); } diff --git a/application/Espo/Tools/Stream/MassNotePreparator.php b/application/Espo/Tools/Stream/MassNotePreparator.php new file mode 100644 index 0000000000..93f5f0649f --- /dev/null +++ b/application/Espo/Tools/Stream/MassNotePreparator.php @@ -0,0 +1,165 @@ +. + * + * 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 $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 $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 $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 $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 $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', []) === []; + } +} diff --git a/application/Espo/Tools/Stream/MyReactionsService.php b/application/Espo/Tools/Stream/MyReactionsService.php new file mode 100644 index 0000000000..d08dfe97ab --- /dev/null +++ b/application/Espo/Tools/Stream/MyReactionsService.php @@ -0,0 +1,155 @@ +. + * + * 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 + { + if (!$this->config->get('useWebSocket')) { + return; + } + + $topic = "streamUpdate.{$note->getParentType()}.{$note->getParentId()}"; + + $this->webSocketSubmission->submit($topic, null, (object) ['noteId' => $note->getId()]); + } +} diff --git a/application/Espo/Tools/Stream/RecordService.php b/application/Espo/Tools/Stream/RecordService.php index a0a96bf51a..dd0ab22b46 100644 --- a/application/Espo/Tools/Stream/RecordService.php +++ b/application/Espo/Tools/Stream/RecordService.php @@ -59,7 +59,8 @@ class RecordService private Helper $helper, private QueryHelper $queryHelper, private Metadata $metadata, - private NoteHelper $noteHelper + private NoteHelper $noteHelper, + private MassNotePreparator $massNotePreparator, ) {} /** @@ -158,6 +159,8 @@ class RecordService $this->prepareNote($item, $scope, $id); } + $this->massNotePreparator->prepare($collection); + return $collection; } @@ -216,6 +219,8 @@ class RecordService $this->prepareNote($e, $scope, $id); } + $this->massNotePreparator->prepare($collection); + $count = $this->entityManager ->getRDBRepositoryByClass(Note::class) ->clone($countBuilder->build()) diff --git a/application/Espo/Tools/Stream/UserRecordService.php b/application/Espo/Tools/Stream/UserRecordService.php index 96df085d23..da6e01babd 100644 --- a/application/Espo/Tools/Stream/UserRecordService.php +++ b/application/Espo/Tools/Stream/UserRecordService.php @@ -68,7 +68,8 @@ class UserRecordService private NoteAccessControl $noteAccessControl, private Helper $helper, private QueryHelper $queryHelper, - private NoteHelper $noteHelper + private NoteHelper $noteHelper, + private MassNotePreparator $massNotePreparator, ) {} /** @@ -614,6 +615,8 @@ class UserRecordService $this->noteHelper->prepare($e); } + $this->massNotePreparator->prepare($collection); + return RecordCollection::createNoCount($collection, $maxSize); } } diff --git a/application/Espo/Tools/UserReaction/NotificationService.php b/application/Espo/Tools/UserReaction/NotificationService.php new file mode 100644 index 0000000000..8f004044e4 --- /dev/null +++ b/application/Espo/Tools/UserReaction/NotificationService.php @@ -0,0 +1,118 @@ +. + * + * 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\UserReaction; + +use Espo\Core\Field\LinkParent; +use Espo\Core\ORM\Entity; +use Espo\Entities\Note; +use Espo\Entities\Notification; +use Espo\Entities\Preferences; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\Tools\Stream\Service; + +class NotificationService +{ + public function __construct( + private EntityManager $entityManager, + private User $user, + private Service $streamService, + ) {} + + public function notifyNote(Note $note, string $type): void + { + $recipientId = $note->getCreatedById(); + + if (!$recipientId || $recipientId === $this->user->getId()) { + return; + } + + $parent = $note->getParent(); + + if ($parent && !$this->streamService->checkIsFollowed($parent, $note->getCreatedById())) { + return; + } + + if (!$this->isEnabledForUser($recipientId)) { + return; + } + + $notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew(); + + $data = [ + 'type' => $type, + 'userId' => $this->user->getId(), + ]; + + $notification + ->setType(Notification::TYPE_USER_REACTION) + ->setUserId($recipientId) + ->setRelated(LinkParent::createFromEntity($note)); + + if ($parent instanceof Entity) { + $notification->setRelatedParent($parent); + $data['entityName'] = $parent->get('name'); + } + + $notification->setData($data); + + $this->entityManager->saveEntity($notification); + } + + private function isEnabledForUser(string $recipientId): bool + { + $recipientPreferences = $this->entityManager->getRepositoryByClass(Preferences::class)->getById($recipientId); + + return $recipientPreferences && $recipientPreferences->get('reactionNotifications'); + } + + public function removeNoteUnread(Note $note, User $user, ?string $type = null): void + { + /** @var Notification[] $notifications */ + $notifications = $this->entityManager + ->getRDBRepositoryByClass(Notification::class) + ->where([ + 'read' => false, + 'createdById' => $user->getId(), + 'type' => Notification::TYPE_USER_REACTION, + 'relatedId' => $note->getId(), + 'relatedType' => $note->getEntityType(), + ]) + ->find(); + + foreach ($notifications as $notification) { + if ($type && $notification->getData()?->type !== $type) { + continue; + } + + $this->entityManager->removeEntity($notification); + } + } +} diff --git a/application/Espo/Tools/UserReaction/ReactionStreamService.php b/application/Espo/Tools/UserReaction/ReactionStreamService.php new file mode 100644 index 0000000000..d17e396832 --- /dev/null +++ b/application/Espo/Tools/UserReaction/ReactionStreamService.php @@ -0,0 +1,144 @@ +. + * + * 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\UserReaction; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Field\DateTime; +use Espo\Core\Select\SearchParams; +use Espo\Core\Select\Where\Item; +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\SelectBuilder; +use Espo\Tools\Stream\UserRecordService; +use stdClass; + +class ReactionStreamService +{ + private const MAX_NOTE_COUNT = 100; + private const MAX_PERIOD = '2 hours'; + + public function __construct( + private EntityManager $entityManager, + private UserRecordService $userRecordService, + private User $user, + private Config $config, + ) {} + + + /** + * Get reaction updates. + * + * @param string[] $noteIds + * @return stdClass[] + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + * @internal + */ + public function getReactionUpdates(DateTime $after, array $noteIds, ?string $userId): array + { + if (count($noteIds) > $this->getMaxCount()) { + throw new Forbidden("Too many note IDs."); + } + + $userId ??= $this->user->getId(); + + $after = $this->getAfter($after); + + $updatedIds = []; + + $query = SelectBuilder::create() + ->from(UserReaction::ENTITY_TYPE) + ->select('parentId') + ->where([ + 'parentId' => $noteIds, + 'parentType' => Note::ENTITY_TYPE, + 'createdAt>=' => $after->toString(), + ]) + ->group('parentId') + ->build(); + + /** @var array{parentId: string}[] $rows */ + $rows = $this->entityManager->getQueryExecutor()->execute($query)->fetchAll(); + + foreach ($rows as $row) { + $updatedIds[] = $row['parentId']; + } + + if (!$updatedIds) { + return []; + } + + $searchParams = SearchParams::create() + ->withSelect(['id', 'reactionCounts', 'myReactions']) + ->withWhereAdded( + Item::createBuilder() + ->setType(Item\Type::IN) + ->setAttribute('id') + ->setValue($updatedIds) + ->build() + ); + + $updatedNotes = $this->userRecordService->find($userId, $searchParams); + + $result = []; + + foreach ($updatedNotes->getCollection() as $note) { + $result[] = (object) [ + 'id' => $note->getId(), + 'myReactions' => $note->get('myReactions'), + 'reactionCounts' => $note->get('reactionCounts') + ]; + } + + return $result; + } + + private function getAfter(DateTime $after): DateTime + { + $afterMax = DateTime::createNow()->modify('-' . self::MAX_PERIOD); + + if ($afterMax->isGreaterThan($after)) { + $after = $afterMax; + } + + return $after; + } + + private function getMaxCount(): int + { + return $this->config->get('streamReactionsCheckMaxSize') ?? self::MAX_NOTE_COUNT; + } +} diff --git a/client/res/templates/record/row-actions/default.tpl b/client/res/templates/record/row-actions/default.tpl index 77a5ab2491..6d7fb3d390 100644 --- a/client/res/templates/record/row-actions/default.tpl +++ b/client/res/templates/record/row-actions/default.tpl @@ -8,17 +8,23 @@