diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index 0740e659ce..7b326b6735 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -269,6 +269,11 @@ class Binding implements BindingProcessor private function bindMisc(Binder $binder): void { + $binder->bindImplementation( + 'Espo\\Core\\Utils\\DateTime\\Clock', + 'Espo\\Core\\Utils\\DateTime\\SystemClock' + ); + $binder->bindImplementation( 'Espo\\Core\\Utils\\Id\\RecordIdGenerator', 'Espo\\Core\\Utils\\Id\\DefaultRecordIdGenerator' diff --git a/application/Espo/Core/FieldProcessing/Reminder/Saver.php b/application/Espo/Core/FieldProcessing/Reminder/Saver.php index 10b1f7b4ef..dcd93eb167 100644 --- a/application/Espo/Core/FieldProcessing/Reminder/Saver.php +++ b/application/Espo/Core/FieldProcessing/Reminder/Saver.php @@ -31,6 +31,7 @@ namespace Espo\Core\FieldProcessing\Reminder; use Espo\Core\Field\DateTime; use Espo\Core\Name\Field; +use Espo\Core\Utils\DateTime\Clock; use Espo\Core\Utils\Id\RecordIdGenerator; use Espo\Core\Utils\Metadata; use Espo\Entities\Preferences; @@ -42,7 +43,6 @@ use Espo\Core\ORM\Entity as CoreEntity; use Espo\Core\FieldProcessing\Saver as SaverInterface; use Espo\Core\FieldProcessing\Saver\Params; use Espo\Core\ORM\EntityManager; - use stdClass; /** @@ -58,7 +58,8 @@ class Saver implements SaverInterface private EntityManager $entityManager, private RecordIdGenerator $idGenerator, private User $user, - private Metadata $metadata + private Metadata $metadata, + private Clock $clock, ) {} public function process(Entity $entity, Params $params): void @@ -119,14 +120,12 @@ class Saver implements SaverInterface $this->getPreferencesReminderList($typeList, $userId, $entityType) : $this->getReminderList($entity, $typeList); - foreach ($reminderList as $item) { - $this->createReminder($entity, $userId, $start, $item); - } + $this->createReminders($reminderList, $entity, $userId, $start); } } /** - * @return object{seconds: int, type: string}[] + * @return array{seconds: int, type: string}[] */ private function getEntityReminderDataList(CoreEntity $entity): array { @@ -146,7 +145,7 @@ class Saver implements SaverInterface ->find(); foreach ($collection as $reminder) { - $dataList[] = (object) [ + $dataList[] = [ 'seconds' => $reminder->getSeconds(), 'type' => $reminder->getType(), ]; @@ -282,7 +281,7 @@ class Saver implements SaverInterface /** * @param string[] $typeList - * @return object{seconds: int, type: string}[] + * @return array{seconds: int, type: string}[] */ private function getReminderList(CoreEntity $entity, array $typeList): array { @@ -302,7 +301,7 @@ class Saver implements SaverInterface /** * @param string[] $typeList - * @return object{seconds: int, type: string}[] + * @return array{seconds: int, type: string}[] */ private function getPreferencesReminderList(array $typeList, string $userId, string $entityType): array { @@ -328,7 +327,7 @@ class Saver implements SaverInterface /** * @param stdClass[] $list * @param string[] $typeList - * @return object{seconds: int, type: string}[] + * @return array{seconds: int, type: string}[] */ private function sanitizeList(array $list, array $typeList): array { @@ -342,7 +341,7 @@ class Saver implements SaverInterface continue; } - $result[] = (object) [ + $result[] = [ 'seconds' => $seconds, 'type' => $type, ]; @@ -352,22 +351,28 @@ class Saver implements SaverInterface } /** - * @param object{seconds: int, type: string} $item + * @param array{seconds: int|null, type: string} $item */ private function createReminder( CoreEntity $entity, string $userId, DateTime $start, - object $item - ): void { + array $item, + ): bool { - $seconds = $item->seconds; - $type = $item->type; + $seconds = $item['seconds']; + $type = $item['type']; - $remindAt = $start->addSeconds(- $seconds); + $now = DateTime::fromDateTime($this->clock->now()); - if ($remindAt->isLessThan(DateTime::createNow())) { - return; + if ($seconds !== null) { + $remindAt = $start->addSeconds(- $seconds); + + if ($remindAt->isLessThan($now)) { + return false; + } + } else { + $remindAt = $now; } $query = $this->entityManager @@ -397,6 +402,8 @@ class Saver implements SaverInterface ->build(); $this->entityManager->getQueryExecutor()->execute($query); + + return true; } private function toRemove(CoreEntity $entity): bool @@ -435,4 +442,70 @@ class Saver implements SaverInterface return in_array($statusFetched, $ignoreStatusList) && !in_array($status, $ignoreStatusList); } + + /** + * @param array{seconds: int, type: string}[] $list + */ + private function createReminders( + array $list, + CoreEntity $entity, + string $userId, + DateTime $start, + ): void { + + if ($list === []) { + return; + } + + $hasPopup = array_filter($list, fn ($it) => $it['type'] === Reminder::TYPE_POPUP) !== []; + $hasEmail = array_filter($list, fn ($it) => $it['type'] === Reminder::TYPE_EMAIL) !== []; + + $hasPopupCreated = false; + $hasEmailCreated = false; + + foreach ($list as $item) { + $isCreated = $this->createReminder( + entity: $entity, + userId: $userId, + start: $start, + item: $item, + ); + + if (!$isCreated) { + continue; + } + + if ($item['type'] === Reminder::TYPE_POPUP) { + $hasPopupCreated = true; + } + + if ($item['type'] === Reminder::TYPE_EMAIL) { + $hasEmailCreated = true; + } + } + + if ($hasPopup && !$hasPopupCreated) { + $this->createReminder( + entity: $entity, + userId: $userId, + start: $start, + item: [ + 'type' => Reminder::TYPE_POPUP, + 'seconds' => null, + ], + ); + } + + if ($hasEmail && !$hasEmailCreated) { + $this->createReminder( + entity: $entity, + userId: $userId, + start: $start, + item: [ + 'type' => Reminder::TYPE_EMAIL, + 'seconds' => null, + ], + ); + } + } } diff --git a/application/Espo/Core/Utils/DateTime/Clock.php b/application/Espo/Core/Utils/DateTime/Clock.php new file mode 100644 index 0000000000..512b6e6f80 --- /dev/null +++ b/application/Espo/Core/Utils/DateTime/Clock.php @@ -0,0 +1,38 @@ +. + * + * 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\DateTime; + +use Psr\Clock\ClockInterface; + +/** + * @since 10.0.0 + */ +interface Clock extends ClockInterface +{} diff --git a/application/Espo/Core/Utils/DateTime/SystemClock.php b/application/Espo/Core/Utils/DateTime/SystemClock.php index fc744a51f8..47aa812c58 100644 --- a/application/Espo/Core/Utils/DateTime/SystemClock.php +++ b/application/Espo/Core/Utils/DateTime/SystemClock.php @@ -30,13 +30,12 @@ namespace Espo\Core\Utils\DateTime; use Espo\Core\Field\DateTime; -use Psr\Clock\ClockInterface; use DateTimeImmutable; /** * @since 9.1.0 */ -class SystemClock implements ClockInterface +class SystemClock implements Clock { public function now(): DateTimeImmutable { diff --git a/tests/integration/Espo/Core/FieldProcessing/ReminderTest.php b/tests/integration/Espo/Core/FieldProcessing/ReminderTest.php index 8420200677..3e34dee51e 100644 --- a/tests/integration/Espo/Core/FieldProcessing/ReminderTest.php +++ b/tests/integration/Espo/Core/FieldProcessing/ReminderTest.php @@ -29,9 +29,15 @@ namespace tests\integration\Espo\Core\FieldProcessing; +use DateTimeImmutable; +use Espo\Core\Authentication\Util\DelayUtil; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingProcessor; use Espo\Core\Field\DateTime; use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\DateTime\Clock; use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Meeting; use Espo\Modules\Crm\Entities\Reminder; use tests\integration\Core\BaseTestCase; @@ -70,4 +76,65 @@ class ReminderTest extends BaseTestCase $this->assertEquals(2, count($reminderList)); } + + public function testFallsToPast(): void + { + $clock = $this->createMock(Clock::class); + $clock->method('now') + ->willReturn(new DateTimeImmutable('2030-01-01 00:00')); + + + $app = $this->createApplication( + binding: new class ($clock) implements BindingProcessor { + + public function __construct(private Clock $clock) {} + + public function process(Binder $binder): void + { + $binder->bindInstance(Clock::class, $this->clock); + } + }, + // @todo Need to reset loaded hooks in the HookManager. Bind EventDispatcher to create repositories, + // so that it is available in the HookManager. + //reuse: true, + ); + $this->setApplication($app); + + $entityManager = $this->getEntityManager(); + + $user = $this->getContainer()->getByClass(User::class); + + $meeting = $entityManager->createEntity(Meeting::ENTITY_TYPE, [ + 'dateStart' => DateTime::fromDateTime($clock->now())->modify('+10 minutes')->toString(), + 'usersIds' => [$user->getId()], + 'reminders' => [ + (object) [ + 'type' => Reminder::TYPE_POPUP, + 'seconds' => 60 * 60, + ], + (object) [ + 'type' => Reminder::TYPE_POPUP, + 'seconds' => 120 * 60, + ], + (object) [ + 'type' => Reminder::TYPE_EMAIL, + 'seconds' => 60 * 60, + ], + (object) [ + 'type' => Reminder::TYPE_EMAIL, + 'seconds' => 120 * 60, + ], + ] + ]); + + $reminderList = $entityManager + ->getRDBRepositoryByClass(Reminder::class) + ->where([ + 'entityId' => $meeting->getId(), + 'entityType' => $meeting->getEntityType(), + ]) + ->find(); + + $this->assertCount(2, $reminderList); + } }