diff --git a/application/Espo/Core/Name/Field.php b/application/Espo/Core/Name/Field.php index 6213bfda07..7dc345d8f6 100644 --- a/application/Espo/Core/Name/Field.php +++ b/application/Espo/Core/Name/Field.php @@ -36,6 +36,7 @@ class Field public const CREATED_AT = 'createdAt'; public const MODIFIED_BY = 'modifiedBy'; public const MODIFIED_AT = 'modifiedAt'; + public const STREAM_UPDATED_AT = 'streamUpdatedAt'; public const ASSIGNED_USER = 'assignedUser'; public const ASSIGNED_USERS = 'assignedUsers'; public const COLLABORATORS = 'collaborators'; diff --git a/application/Espo/Core/Utils/Metadata/AdditionalBuilder/StreamUpdatedAtField.php b/application/Espo/Core/Utils/Metadata/AdditionalBuilder/StreamUpdatedAtField.php new file mode 100644 index 0000000000..d03761e4c3 --- /dev/null +++ b/application/Espo/Core/Utils/Metadata/AdditionalBuilder/StreamUpdatedAtField.php @@ -0,0 +1,67 @@ +. + * + * 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\Utils\Metadata\AdditionalBuilder; + +use Espo\Core\Name\Field; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Metadata\AdditionalBuilder; +use stdClass; + +class StreamUpdatedAtField implements AdditionalBuilder +{ + public function build(stdClass $data): void + { + if (!isset($data->entityDefs)) { + return; + } + + $field = Field::STREAM_UPDATED_AT; + + foreach (get_object_vars($data->entityDefs) as $entityType => $entityDefsItem) { + $hasStream = $data->scopes?->$entityType?->stream; + + if (!$hasStream) { + continue; + } + + if (isset($entityDefsItem?->fields->$field)) { + continue; + } + + $entityDefsItem->fields ??= (object) []; + + $entityDefsItem->fields->$field = (object) [ + 'type' => FieldType::DATETIME, + 'readOnly' => true, + 'customizationReadOnlyDisabled' => true, + ]; + } + } +} diff --git a/application/Espo/Entities/Note.php b/application/Espo/Entities/Note.php index 6bd442fefa..ca53677c45 100644 --- a/application/Espo/Entities/Note.php +++ b/application/Espo/Entities/Note.php @@ -226,7 +226,7 @@ class Note extends Entity return $this->set('type', $type); } - public function setParent(LinkParent|Entity $parent): self + public function setParent(LinkParent|OrmEntity $parent): self { if ($parent instanceof LinkParent) { $this->setValueObject(Field::PARENT, $parent); @@ -239,7 +239,7 @@ class Note extends Entity return $this; } - public function setRelated(LinkParent|Entity $related): self + public function setRelated(LinkParent|OrmEntity $related): self { if ($related instanceof LinkParent) { $this->setValueObject('related', $related); @@ -252,7 +252,7 @@ class Note extends Entity return $this; } - public function setSuperParent(LinkParent|Entity $superParent): self + public function setSuperParent(LinkParent|OrmEntity $superParent): self { if ($superParent instanceof LinkParent) { $this->set('superParentId', $superParent->getId()); @@ -360,6 +360,11 @@ class Note extends Entity return $this->relations->getOne(Field::PARENT); } + public function getSuperParent(): ?OrmEntity + { + return $this->relations->getOne('superParent'); + } + /** * @return iterable */ diff --git a/application/Espo/Hooks/Common/Stream.php b/application/Espo/Hooks/Common/Stream.php index 62d7531591..cb1ee49878 100644 --- a/application/Espo/Hooks/Common/Stream.php +++ b/application/Espo/Hooks/Common/Stream.php @@ -33,6 +33,7 @@ use Espo\Core\Hook\Hook\AfterRelate; use Espo\Core\Hook\Hook\AfterRemove; use Espo\Core\Hook\Hook\AfterSave; use Espo\Core\Hook\Hook\AfterUnrelate; +use Espo\Core\Hook\Hook\BeforeSave; use Espo\Core\ORM\Repository\Option\SaveOption; use Espo\ORM\Entity; use Espo\ORM\Repository\Option\RelateOptions; @@ -42,18 +43,28 @@ use Espo\ORM\Repository\Option\UnrelateOptions; use Espo\Tools\Stream\HookProcessor; /** + * @implements BeforeSave * @implements AfterSave * @implements AfterRemove * @implements AfterRelate * @implements AfterUnrelate */ -class Stream implements AfterSave, AfterRemove, AfterRelate, AfterUnrelate +class Stream implements BeforeSave, AfterSave, AfterRemove, AfterRelate, AfterUnrelate { public static int $order = 9; public function __construct(private HookProcessor $processor) {} + public function beforeSave(Entity $entity, SaveOptions $options): void + { + if ($options->get(SaveOption::SILENT)) { + return; + } + + $this->processor->beforeSave($entity, $options); + } + public function afterSave(Entity $entity, SaveOptions $options): void { if ($options->get(SaveOption::SILENT)) { diff --git a/application/Espo/Hooks/Note/StreamUpdatedAt.php b/application/Espo/Hooks/Note/StreamUpdatedAt.php new file mode 100644 index 0000000000..bb485f923a --- /dev/null +++ b/application/Espo/Hooks/Note/StreamUpdatedAt.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\Hooks\Note; + +use Espo\Core\Hook\Hook\AfterSave; +use Espo\ORM\Entity; +use Espo\ORM\Repository\Option\SaveOptions; +use Espo\Entities\Note; +use Espo\Tools\Stream\Service; + +/** + * @implements AfterSave + */ +class StreamUpdatedAt implements AfterSave +{ + public function __construct(private Service $service) + {} + + public function afterSave(Entity $entity, SaveOptions $options): void + { + if (!$entity->isNew()) { + return; + } + + if ( + $entity->getType() !== Note::TYPE_POST || + !$entity->getParentType() || + !$this->service->checkIsEnabled($entity->getParentType()) + ) { + return; + } + + if (!$entity->getParent()) { + return; + } + + $this->service->updateStreamUpdatedAt($entity->getParent()); + } +} diff --git a/application/Espo/Modules/Crm/Resources/layouts/Account/filters.json b/application/Espo/Modules/Crm/Resources/layouts/Account/filters.json index ead63cc60d..aacf83ca93 100644 --- a/application/Espo/Modules/Crm/Resources/layouts/Account/filters.json +++ b/application/Espo/Modules/Crm/Resources/layouts/Account/filters.json @@ -4,6 +4,7 @@ "createdAt", "createdBy", "modifiedAt", + "streamUpdatedAt", "type", "industry", "description", diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index f842808b6d..7c4465ad38 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -478,6 +478,7 @@ "modifiedAt": "Modified At", "createdBy": "Created By", "modifiedBy": "Modified By", + "streamUpdatedAt": "Stream Updated At", "description": "Description", "address": "Address", "phoneNumber": "Phone", diff --git a/application/Espo/Resources/metadata/app/metadata.json b/application/Espo/Resources/metadata/app/metadata.json index 3f88a254e2..d219f5dd7f 100644 --- a/application/Espo/Resources/metadata/app/metadata.json +++ b/application/Espo/Resources/metadata/app/metadata.json @@ -44,6 +44,7 @@ ], "additionalBuilderClassNameList": [ "Espo\\Core\\Utils\\Metadata\\AdditionalBuilder\\Fields", - "Espo\\Core\\Utils\\Metadata\\AdditionalBuilder\\DeleteIdField" + "Espo\\Core\\Utils\\Metadata\\AdditionalBuilder\\DeleteIdField", + "Espo\\Core\\Utils\\Metadata\\AdditionalBuilder\\StreamUpdatedAtField" ] } diff --git a/application/Espo/Tools/EntityManager/NameUtil.php b/application/Espo/Tools/EntityManager/NameUtil.php index 7a30f97c8a..d20fa69d9e 100644 --- a/application/Espo/Tools/EntityManager/NameUtil.php +++ b/application/Espo/Tools/EntityManager/NameUtil.php @@ -148,6 +148,11 @@ class NameUtil Field::ASSIGNED_USER, Field::ASSIGNED_USERS, Field::COLLABORATORS, + Field::STREAM_UPDATED_AT, + Field::CREATED_BY, + Field::CREATED_AT, + Field::MODIFIED_BY, + Field::MODIFIED_AT, ]; /** @@ -169,6 +174,8 @@ class NameUtil Field::ASSIGNED_USER, Field::ASSIGNED_USERS, Field::COLLABORATORS, + Field::CREATED_BY, + Field::MODIFIED_BY, ]; /** diff --git a/application/Espo/Tools/Stream/HookProcessor.php b/application/Espo/Tools/Stream/HookProcessor.php index 7507f88486..5b3bf964d5 100644 --- a/application/Espo/Tools/Stream/HookProcessor.php +++ b/application/Espo/Tools/Stream/HookProcessor.php @@ -29,6 +29,7 @@ namespace Espo\Tools\Stream; +use Espo\Core\Field\DateTime; use Espo\Core\Name\Field; use Espo\Core\ORM\Repository\Option\SaveOption; use Espo\Core\ORM\Entity as CoreEntity; @@ -45,6 +46,7 @@ use Espo\ORM\Entity; use Espo\ORM\Defs\RelationDefs; use Espo\ORM\Repository\Option\RemoveOptions; +use Espo\ORM\Repository\Option\SaveOptions; use Espo\Tools\Stream\Service as Service; use Espo\Tools\Stream\Jobs\AutoFollow as AutoFollowJob; use Espo\Tools\Stream\Jobs\ControlFollowers as ControlFollowersJob; @@ -74,6 +76,19 @@ class HookProcessor private JobSchedulerFactory $jobSchedulerFactory ) {} + public function beforeSave(Entity $entity, SaveOptions $options): void + { + if ( + !$this->checkHasStream($entity->getEntityType()) || + $options->get(self::OPTION_NO_STREAM) || + $options->get(SaveOption::SILENT) + ) { + return; + } + + $this->processStreamUpdatedAt($entity); + } + /** * @param array $options */ @@ -600,11 +615,11 @@ class HookProcessor $auditedForeign = $this->metadata->get(['entityDefs', $foreignEntityType, 'links', $foreignLink, 'audited']); if ($audited) { - $this->service->noteRelate($foreignEntity, $entityType, $entity->getId()); + $this->service->noteRelate($foreignEntity, $entity); } if ($auditedForeign) { - $this->service->noteRelate($entity, $foreignEntity->getEntityType(), $foreignEntity->getId()); + $this->service->noteRelate($entity, $foreignEntity); } } @@ -633,14 +648,14 @@ class HookProcessor $auditedForeign = $this->metadata->get(['entityDefs', $foreignEntityType, 'links', $foreignLink, 'audited']); if ($audited) { - $this->service->noteUnrelate($foreignEntity, $entityType, $entity->getId()); + $this->service->noteUnrelate($foreignEntity, $entity); // @todo // Add time period (a few minutes). If before, remove RELATE note, don't create 'unrelate' if before. } if ($auditedForeign) { - $this->service->noteUnrelate($entity, $foreignEntity->getEntityType(), $foreignEntity->getId()); + $this->service->noteUnrelate($entity, $foreignEntity); // @todo // Add time period (a few minutes). If before, remove RELATE note, don't create 'unrelate' if before. @@ -664,4 +679,13 @@ class HookProcessor $this->service->handleAudited($entity, $options); } } + + private function processStreamUpdatedAt(Entity $entity): void + { + if (!$this->service->checkEntityNeedsUpdatedAt($entity)) { + return; + } + + $entity->set(Field::STREAM_UPDATED_AT, DateTime::createNow()->toString()); + } } diff --git a/application/Espo/Tools/Stream/Service.php b/application/Espo/Tools/Stream/Service.php index f78b9ee7fc..bcdf2b3efe 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\Field\DateTime; use Espo\Core\Field\LinkMultiple; use Espo\Core\Field\LinkParent; use Espo\Core\Name\Field; @@ -486,8 +487,8 @@ class Service $note = $this->getNewNote(); $note->setType(Note::TYPE_EMAIL_RECEIVED); - $note->setParent(LinkParent::createFromEntity($entity)); - $note->setRelated(LinkParent::create(Email::ENTITY_TYPE, $email->getId())); + $note->setParent($entity); + $note->setRelated($email); $this->processNoteTeamsUsers($note, $email); @@ -534,6 +535,9 @@ class Service $note->setData((object) $data); $this->entityManager->saveEntity($note); + + // @todo Also for the super-parent. + $this->updateStreamUpdatedAt($entity); } public function noteEmailSent(Entity $entity, Email $email): void @@ -543,8 +547,8 @@ class Service $note = $this->getNewNote(); $note->setType(Note::TYPE_EMAIL_SENT); - $note->setParent(LinkParent::createFromEntity($entity)); - $note->setRelated(LinkParent::create(Email::ENTITY_TYPE, $email->getId())); + $note->setParent($entity); + $note->setRelated($email); $this->processNoteTeamsUsers($note, $email); @@ -590,6 +594,9 @@ class Service $note->set('data', (object) $data); $this->entityManager->saveEntity($note); + + // @todo Also for the super-parent. + $this->updateStreamUpdatedAt($entity); } /** @@ -696,7 +703,7 @@ class Service $note->setType(Note::TYPE_CREATE_RELATED); $note->setParent(LinkParent::create($parentType, $parentId)); - $note->setRelated(LinkParent::createFromEntity($entity)); + $note->setRelated($entity); $this->processNoteTeamsUsers($note, $entity); @@ -714,7 +721,7 @@ class Service /** * @param array $options */ - public function noteRelate(Entity $entity, string $parentType, string $parentId, array $options = []): void + public function noteRelate(Entity $entity, Entity $parent, array $options = []): void { $entityType = $entity->getEntityType(); @@ -723,8 +730,8 @@ class Service ->select([Attribute::ID]) ->where([ 'type' => Note::TYPE_RELATE, - 'parentId' => $parentId, - 'parentType' => $parentType, + 'parentId' => $parent->getId(), + 'parentType' => $parent->getEntityType(), 'relatedId' => $entity->getId(), 'relatedType' => $entityType, ]) @@ -737,8 +744,8 @@ class Service $note = $this->getNewNote(); $note->setType(Note::TYPE_RELATE); - $note->setParent(LinkParent::create($parentType, $parentId)); - $note->setRelated(LinkParent::createFromEntity($entity)); + $note->setParent($parent); + $note->setRelated($entity); $this->processNoteTeamsUsers($note, $entity); @@ -749,12 +756,14 @@ class Service } $this->entityManager->saveEntity($note, $noteOptions); + + $this->updateStreamUpdatedAt($parent); } /** * @param array $options */ - public function noteUnrelate(Entity $entity, string $parentType, string $parentId, array $options = []): void + public function noteUnrelate(Entity $entity, Entity $parent, array $options = []): void { $entityType = $entity->getEntityType(); @@ -763,8 +772,8 @@ class Service ->select([Attribute::ID]) ->where([ 'type' => Note::TYPE_UNRELATE, - 'parentId' => $parentId, - 'parentType' => $parentType, + 'parentId' => $parent->getId(), + 'parentType' => $parent->getEntityType(), 'relatedId' => $entity->getId(), 'relatedType' => $entityType, ]) @@ -777,8 +786,8 @@ class Service $note = $this->getNewNote(); $note->setType(Note::TYPE_UNRELATE); - $note->setParent(LinkParent::create($parentType, $parentId)); - $note->setRelated(LinkParent::createFromEntity($entity)); + $note->setParent($parent); + $note->setRelated($entity); $this->processNoteTeamsUsers($note, $entity); @@ -789,6 +798,8 @@ class Service } $this->entityManager->saveEntity($note, $noteOptions); + + $this->updateStreamUpdatedAt($parent); } /** @@ -907,6 +918,69 @@ class Service return $this->auditedFieldsCache[$entityType]; } + /** + * @since 9.0.0 + * @internal + */ + public function checkEntityNeedsUpdatedAt(Entity $entity): bool + { + if ($entity->isNew() && $entity->get(Field::STREAM_UPDATED_AT)) { + return false; + } + + return + $entity->isNew() || + $this->hasAuditedFieldChanged($entity) || + $this->hasStatusFieldChanged($entity) || + $this->hasAssignedUserChanged($entity); + } + + private function hasAssignedUserChanged(Entity $entity): bool + { + if ( + $entity->hasAttribute(Field::ASSIGNED_USER . 'Id') && + $entity->isAttributeChanged(Field::ASSIGNED_USER . 'Id') + ) { + return true; + } + + if ( + $entity instanceof CoreEntity && + $entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS) && + $entity->isAttributeChanged(self::FIELD_ASSIGNED_USERS . 'Ids') + ) { + return true; + } + + return false; + } + + private function hasStatusFieldChanged(Entity $entity): bool + { + $field = $this->getStatusField($entity->getEntityType()); + + if (!$field) { + return false; + } + + return $entity->isAttributeChanged($field); + } + + private function hasAuditedFieldChanged(Entity $entity): bool + { + $auditedFields = $this->getAuditedFieldsData($entity); + + foreach ($auditedFields as $item) { + foreach ($item['actualList'] as $attribute) { + if ($entity->isAttributeChanged($attribute)) { + return true; + } + } + } + + return false; + } + /** * @param array $options */ @@ -1285,4 +1359,14 @@ class Service { return (bool) $this->metadata->get("scopes.$entityType.stream"); } + + /** + * @since 9.0.0 + */ + public function updateStreamUpdatedAt(Entity $entity): void + { + $entity->set(Field::STREAM_UPDATED_AT, DateTime::createNow()->toString()); + + $this->entityManager->saveEntity($entity, [SaveOption::SKIP_ALL => true]); + } } diff --git a/tests/integration/Espo/Note/AclTest.php b/tests/integration/Espo/Note/AclTest.php index 66f1799ca2..72eb22a744 100644 --- a/tests/integration/Espo/Note/AclTest.php +++ b/tests/integration/Espo/Note/AclTest.php @@ -109,7 +109,7 @@ class AclTest extends \tests\integration\Core\BaseTestCase 'parentType' => $account->getEntityType(), ]); - $streamService->noteRelate($meeting, $account->getEntityType(), $account->getId()); + $streamService->noteRelate($meeting, $account); $note2 = $em ->getRDBRepository('Note')