Initial commit

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

View File

@@ -0,0 +1,74 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\EmailAddress;
use Espo\Repositories\EmailAddress as Repository;
use Espo\ORM\Entity;
use Espo\Entities\EmailAddress;
use Espo\Entities\User;
use Espo\Core\AclManager;
use Espo\Core\ORM\EntityManager;
class AccessChecker
{
public function __construct(
private EntityManager $entityManager,
private AclManager $aclManager
) {}
public function checkEdit(User $user, EmailAddress $emailAddress, Entity $excludeEntity): bool
{
/** @var Repository $repository */
$repository = $this->entityManager->getRepository('EmailAddress');
$entityWithSameAddressList = $repository->getEntityListByAddressId($emailAddress->getId(), $excludeEntity);
foreach ($entityWithSameAddressList as $e) {
if ($this->aclManager->checkEntityEdit($user, $e)) {
continue;
}
if (
$e instanceof User &&
$e->isPortal() &&
$excludeEntity->getEntityType() === 'Contact' &&
$e->get('contactId') === $excludeEntity->getEntityType()
) {
continue;
}
return false;
}
return true;
}
}

View File

@@ -0,0 +1,67 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\EmailAddress;
use Espo\Repositories\EmailAddress as Repository;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\ORM\EntityManager;
use Espo\ORM\Defs as OrmDefs;
/**
* @implements LoaderInterface<Entity>
*/
class Loader implements LoaderInterface
{
public function __construct(private OrmDefs $ormDefs, private EntityManager $entityManager)
{}
public function process(Entity $entity, Params $params): void
{
$entityDefs = $this->ormDefs->getEntity($entity->getEntityType());
if (!$entityDefs->hasField('emailAddress')) {
return;
}
if ($entityDefs->getField('emailAddress')->getType() !== 'email') {
return;
}
/** @var Repository $repository */
$repository = $this->entityManager->getRepository('EmailAddress');
$emailAddressData = $repository->getEmailAddressData($entity);
$entity->set('emailAddressData', $emailAddressData);
$entity->setFetched('emailAddressData', $emailAddressData);
}
}

View File

@@ -0,0 +1,567 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\EmailAddress;
use Espo\Core\Name\Link;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\ORM\Type\FieldType;
use Espo\Entities\EmailAddress;
use Espo\ORM\Name\Attribute;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use Espo\ORM\Entity;
use Espo\Core\ApplicationState;
use Espo\Core\FieldProcessing\Saver as SaverInterface;
use Espo\Core\FieldProcessing\Saver\Params;
use Espo\Core\ORM\EntityManager;
/**
* @implements SaverInterface<Entity>
*/
class Saver implements SaverInterface
{
private const ATTR_EMAIL_ADDRESS = 'emailAddress';
private const ATTR_EMAIL_ADDRESS_DATA = 'emailAddressData';
private const ATTR_EMAIL_ADDRESS_IS_OPTED_OUT = 'emailAddressIsOptedOut';
private const ATTR_EMAIL_ADDRESS_IS_INVALID = 'emailAddressIsInvalid';
private const LINK_EMAIL_ADDRESSES = Link::EMAIL_ADDRESSES;
public function __construct(
private EntityManager $entityManager,
private ApplicationState $applicationState,
private AccessChecker $accessChecker
) {}
public function process(Entity $entity, Params $params): void
{
$entityType = $entity->getEntityType();
$defs = $this->entityManager->getDefs()->getEntity($entityType);
if (!$defs->hasField(self::ATTR_EMAIL_ADDRESS)) {
return;
}
if ($defs->getField(self::ATTR_EMAIL_ADDRESS)->getType() !== FieldType::EMAIL) {
return;
}
$emailAddressData = null;
if ($entity->has(self::ATTR_EMAIL_ADDRESS_DATA)) {
$emailAddressData = $entity->get(self::ATTR_EMAIL_ADDRESS_DATA);
}
if ($emailAddressData !== null && $entity->isAttributeChanged(self::ATTR_EMAIL_ADDRESS_DATA)) {
$this->storeData($entity);
return;
}
if ($entity->has(self::ATTR_EMAIL_ADDRESS)) {
$this->storePrimary($entity);
}
}
private function storeData(Entity $entity): void
{
if (!$entity->has(self::ATTR_EMAIL_ADDRESS_DATA)) {
return;
}
$emailAddressValue = $entity->get(self::ATTR_EMAIL_ADDRESS);
if (is_string($emailAddressValue)) {
$emailAddressValue = trim($emailAddressValue);
}
$emailAddressData = $entity->get(self::ATTR_EMAIL_ADDRESS_DATA);
if (!is_array($emailAddressData)) {
return;
}
$noPrimary = array_filter($emailAddressData, fn ($item) => !empty($item->primary)) === [];
if ($noPrimary && $emailAddressData !== []) {
$emailAddressData[0]->primary = true;
}
$keyList = [];
$keyPreviousList = [];
$previousEmailAddressData = [];
if (!$entity->isNew()) {
/** @var EmailAddressRepository $repository */
$repository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
$previousEmailAddressData = $repository->getEmailAddressData($entity);
}
$hash = (object) [];
$hashPrevious = (object) [];
foreach ($emailAddressData as $row) {
$key = trim($row->emailAddress);
if (empty($key)) {
continue;
}
$key = strtolower($key);
$hash->$key = [
'primary' => !empty($row->primary),
'optOut' => !empty($row->optOut),
'invalid' => !empty($row->invalid),
'emailAddress' => trim($row->emailAddress),
];
$keyList[] = $key;
}
if (
$entity->has(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT) &&
(
$entity->isNew() ||
(
$entity->hasFetched(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT) &&
$entity->isAttributeChanged(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT)
)
) &&
$emailAddressValue
) {
$key = strtolower($emailAddressValue);
if ($key && isset($hash->$key)) {
$hash->{$key}['optOut'] = (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT);
}
}
if (
$entity->has(self::ATTR_EMAIL_ADDRESS_IS_INVALID) &&
(
$entity->isNew() ||
(
$entity->hasFetched(self::ATTR_EMAIL_ADDRESS_IS_INVALID) &&
$entity->isAttributeChanged(self::ATTR_EMAIL_ADDRESS_IS_INVALID)
)
) &&
$emailAddressValue
) {
$key = strtolower($emailAddressValue);
if ($key && isset($hash->$key)) {
$hash->{$key}['invalid'] = (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_INVALID);
}
}
foreach ($previousEmailAddressData as $row) {
$key = $row->lower;
if (empty($key)) {
continue;
}
$hashPrevious->$key = [
'primary' => (bool) $row->primary,
'optOut' => (bool) $row->optOut,
'invalid' => (bool) $row->invalid,
'emailAddress' => $row->emailAddress,
];
$keyPreviousList[] = $key;
}
$primary = null;
$toCreateList = [];
$toUpdateList = [];
$toRemoveList = [];
$revertData = [];
foreach ($keyList as $key) {
$new = true;
$changed = false;
if ($hash->{$key}['primary']) {
$primary = $key;
}
if (property_exists($hashPrevious, $key)) {
$new = false;
$changed =
$hash->{$key}['optOut'] != $hashPrevious->{$key}['optOut'] ||
$hash->{$key}['invalid'] != $hashPrevious->{$key}['invalid'] ||
$hash->{$key}['emailAddress'] !== $hashPrevious->{$key}['emailAddress'];
if (
$hash->{$key}['primary'] &&
$hash->{$key}['primary'] === $hashPrevious->{$key}['primary']
) {
$primary = null;
}
}
if ($new) {
$toCreateList[] = $key;
}
if ($changed) {
$toUpdateList[] = $key;
}
}
foreach ($keyPreviousList as $key) {
if (!property_exists($hash, $key)) {
$toRemoveList[] = $key;
}
}
foreach ($toRemoveList as $address) {
$emailAddress = $this->getByAddress($address);
if (!$emailAddress) {
continue;
}
$delete = $this->entityManager
->getQueryBuilder()
->delete()
->from('EntityEmailAddress')
->where([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'emailAddressId' => $emailAddress->getId(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($delete);
}
foreach ($toUpdateList as $address) {
$emailAddress = $this->getByAddress($address);
if ($emailAddress) {
$skipSave = $this->checkChangeIsForbidden($emailAddress, $entity);
if (!$skipSave) {
$emailAddress->set([
'optOut' => $hash->{$address}['optOut'],
'invalid' => $hash->{$address}['invalid'],
'name' => $hash->{$address}['emailAddress']
]);
$this->entityManager->saveEntity($emailAddress);
} else {
$revertData[$address] = [
'optOut' => $emailAddress->isOptedOut(),
'invalid' => $emailAddress->isInvalid(),
];
}
}
}
foreach ($toCreateList as $address) {
$emailAddress = $this->getByAddress($address);
if (!$emailAddress) {
$emailAddress = $this->entityManager->getNewEntity(EmailAddress::ENTITY_TYPE);
$emailAddress->set([
'name' => $hash->{$address}['emailAddress'],
'optOut' => $hash->{$address}['optOut'],
'invalid' => $hash->{$address}['invalid'],
]);
$this->entityManager->saveEntity($emailAddress);
} else {
$skipSave = $this->checkChangeIsForbidden($emailAddress, $entity);
if (!$skipSave) {
if (
$emailAddress->get('optOut') != $hash->{$address}['optOut'] ||
$emailAddress->get('invalid') != $hash->{$address}['invalid'] ||
$emailAddress->get(self::ATTR_EMAIL_ADDRESS) != $hash->{$address}['emailAddress']
) {
$emailAddress->set([
'optOut' => $hash->{$address}['optOut'],
'invalid' => $hash->{$address}['invalid'],
'name' => $hash->{$address}['emailAddress']
]);
$this->entityManager->saveEntity($emailAddress);
}
} else {
$revertData[$address] = [
'optOut' => $emailAddress->isOptedOut(),
'invalid' => $emailAddress->isInvalid(),
];
}
}
$entityEmailAddress = $this->entityManager->getNewEntity('EntityEmailAddress');
$entityEmailAddress->set([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'emailAddressId' => $emailAddress->getId(),
'primary' => $address === $primary,
Attribute::DELETED => false,
]);
$mapper = $this->entityManager->getMapper();
$mapper->insertOnDuplicateUpdate($entityEmailAddress, [
'primary',
Attribute::DELETED,
]);
}
if ($primary) {
$emailAddress = $this->getByAddress($primary);
$entity->set(self::ATTR_EMAIL_ADDRESS, $primary);
if ($emailAddress) {
$update1 = $this->entityManager
->getQueryBuilder()
->update()
->in('EntityEmailAddress')
->set(['primary' => false])
->where([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'primary' => true,
Attribute::DELETED => false,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update1);
$update2 = $this->entityManager
->getQueryBuilder()
->update()
->in('EntityEmailAddress')
->set(['primary' => true])
->where([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'emailAddressId' => $emailAddress->getId(),
Attribute::DELETED => false,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update2);
}
}
if (!empty($revertData)) {
foreach ($emailAddressData as $row) {
if (empty($revertData[$row->emailAddress])) {
continue;
}
$row->optOut = $revertData[$row->emailAddress]['optOut'];
$row->invalid = $revertData[$row->emailAddress]['invalid'];
}
$entity->set(self::ATTR_EMAIL_ADDRESS_DATA, $emailAddressData);
}
}
private function storePrimary(Entity $entity): void
{
if (!$entity->has(self::ATTR_EMAIL_ADDRESS)) {
return;
}
$emailAddressValue = $entity->get(self::ATTR_EMAIL_ADDRESS);
if (is_string($emailAddressValue)) {
$emailAddressValue = trim($emailAddressValue);
}
if (!empty($emailAddressValue)) {
$this->storePrimaryNotEmpty($entity, $emailAddressValue);
return;
}
$emailAddressValueOld = $entity->getFetched(self::ATTR_EMAIL_ADDRESS);
if (!empty($emailAddressValueOld)) {
$emailAddressOld = $this->getByAddress($emailAddressValueOld);
if ($emailAddressOld) {
$this->entityManager
->getRelation($entity, self::LINK_EMAIL_ADDRESSES)
->unrelate($emailAddressOld, [SaveOption::SKIP_HOOKS => true]);
}
}
}
private function storePrimaryNotEmpty(Entity $entity, string $emailAddressValue): void
{
if ($emailAddressValue === $entity->getFetched(self::ATTR_EMAIL_ADDRESS)) {
if (
$entity->has(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT) &&
(
$entity->isNew() ||
(
$entity->hasFetched(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT) &&
$entity->isAttributeChanged(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT)
)
)
) {
$this->markAddressOptedOut($emailAddressValue,
(bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT));
}
if (
$entity->has(self::ATTR_EMAIL_ADDRESS_IS_INVALID) &&
(
$entity->isNew() ||
(
$entity->hasFetched(self::ATTR_EMAIL_ADDRESS_IS_INVALID) &&
$entity->isAttributeChanged(self::ATTR_EMAIL_ADDRESS_IS_INVALID)
)
)
) {
$this->markAddressInvalid($emailAddressValue, (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_INVALID));
}
return;
}
$entityRepository = $this->entityManager->getRDBRepository($entity->getEntityType());
$emailAddressNew = $this->entityManager
->getRDBRepository(EmailAddress::ENTITY_TYPE)
->where([
'lower' => strtolower($emailAddressValue),
])
->findOne();
if (!$emailAddressNew) {
/** @var EmailAddress $emailAddressNew */
$emailAddressNew = $this->entityManager->getNewEntity(EmailAddress::ENTITY_TYPE);
$emailAddressNew->setAddress($emailAddressValue);
if ($entity->has(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT)) {
$emailAddressNew->setOptedOut((bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT));
}
if ($entity->has(self::ATTR_EMAIL_ADDRESS_IS_INVALID)) {
$emailAddressNew->setInvalid((bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_INVALID));
}
$this->entityManager->saveEntity($emailAddressNew);
}
$emailAddressValueOld = $entity->getFetched(self::ATTR_EMAIL_ADDRESS);
if (!empty($emailAddressValueOld)) {
$emailAddressOld = $this->getByAddress($emailAddressValueOld);
if ($emailAddressOld) {
$entityRepository
->getRelation($entity, self::LINK_EMAIL_ADDRESSES)
->unrelate($emailAddressOld, [SaveOption::SKIP_HOOKS => true]);
}
}
$entityRepository
->getRelation($entity, self::LINK_EMAIL_ADDRESSES)
->relate($emailAddressNew, null, [SaveOption::SKIP_HOOKS => true]);
if ($entity->has(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT)) {
$this->markAddressOptedOut($emailAddressValue, (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT));
}
if ($entity->has(self::ATTR_EMAIL_ADDRESS_IS_INVALID)) {
$this->markAddressInvalid($emailAddressValue, (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_INVALID));
}
$updateQuery = $this->entityManager
->getQueryBuilder()
->update()
->in('EntityEmailAddress')
->set(['primary' => true])
->where([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'emailAddressId' => $emailAddressNew->getId(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($updateQuery);
}
private function getByAddress(string $address): ?EmailAddress
{
/** @var EmailAddressRepository $repository */
$repository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
return $repository->getByAddress($address);
}
private function markAddressOptedOut(string $address, bool $isOptedOut = true): void
{
/** @var EmailAddressRepository $repository */
$repository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
$repository->markAddressOptedOut($address, $isOptedOut);
}
private function markAddressInvalid(string $address, bool $isInvalid = true): void
{
/** @var EmailAddressRepository $repository */
$repository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
$repository->markAddressInvalid($address, $isInvalid);
}
private function checkChangeIsForbidden(EmailAddress $emailAddress, Entity $entity): bool
{
if (!$this->applicationState->hasUser()) {
return true;
}
$user = $this->applicationState->getUser();
// @todo Check if not modified by system.
return !$this->accessChecker->checkEdit($user, $emailAddress, $entity);
}
}

View File

@@ -0,0 +1,156 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\File;
use Espo\Entities\Attachment;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Saver as SaverInterface;
use Espo\Core\FieldProcessing\Saver\Params;
use Espo\Core\ORM\EntityManager;
/**
* @implements SaverInterface<Entity>
*/
class Saver implements SaverInterface
{
private EntityManager $entityManager;
/** @var array<string, string[]>*/
private $fieldListMapCache = [];
public function __construct(EntityManager $entityManager)
{
$this->entityManager = $entityManager;
}
public function process(Entity $entity, Params $params): void
{
foreach ($this->getFieldList($entity->getEntityType()) as $name) {
$this->processItem($entity, $name);
}
}
private function processItem(Entity $entity, string $name): void
{
$attribute = $name . 'Id';
if (!$entity->isAttributeChanged($attribute)) {
return;
}
$id = $entity->get($attribute);
if (!$id) {
$this->removePrevious($entity, $attribute);
return;
}
$attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $id);
if (!$attachment) {
return;
}
$attachment->set([
'relatedId' => $entity->getId(),
'relatedType' => $entity->getEntityType(),
]);
$this->entityManager->saveEntity($attachment);
if ($entity->isNew()) {
return;
}
$this->removePrevious($entity, $attribute);
}
private function removePrevious(Entity $entity, string $attribute): void
{
$previousId = $entity->getFetched($attribute);
if (!$previousId) {
return;
}
$previousAttachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $previousId);
if (!$previousAttachment) {
return;
}
$this->entityManager->removeEntity($previousAttachment);
}
/**
* @return string[]
*/
private function getFieldList(string $entityType): array
{
if (array_key_exists($entityType, $this->fieldListMapCache)) {
return $this->fieldListMapCache[$entityType];
}
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entityType);
$list = [];
foreach ($entityDefs->getRelationNameList() as $name) {
$defs = $entityDefs->getRelation($name);
$type = $defs->getType();
if (!$defs->hasForeignEntityType()) {
continue;
}
$foreignEntityType = $defs->getForeignEntityType();
if ($type !== Entity::BELONGS_TO || $foreignEntityType !== 'Attachment') {
continue;
}
if (!$entityDefs->hasAttribute($name . 'Id')) {
continue;
}
$list[] = $name;
}
$this->fieldListMapCache[$entityType] = $list;
return $list;
}
}

View File

@@ -0,0 +1,111 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Link;
use Espo\Core\ORM\Type\FieldType;
use Espo\ORM\Entity;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\ORM\Defs as OrmDefs;
/**
* @implements LoaderInterface<Entity>
*/
class HasOneLoader implements LoaderInterface
{
private OrmDefs $ormDefs;
/**
* @var array<string, string[]>
*/
private $fieldListCacheMap = [];
public function __construct(OrmDefs $ormDefs)
{
$this->ormDefs = $ormDefs;
}
public function process(Entity $entity, Params $params): void
{
if (!$entity instanceof CoreEntity) {
return;
}
foreach ($this->getFieldList($entity->getEntityType()) as $field) {
if ($entity->get($field . 'Name')) {
continue;
}
$entity->loadLinkField($field);
}
}
/**
* @return string[]
*/
private function getFieldList(string $entityType): array
{
if (array_key_exists($entityType, $this->fieldListCacheMap)) {
return $this->fieldListCacheMap[$entityType];
}
$list = [];
$entityDefs = $this->ormDefs->getEntity($entityType);
foreach ($entityDefs->getFieldList() as $fieldDefs) {
if ($fieldDefs->getType() !== FieldType::LINK) {
continue;
}
if ($fieldDefs->getParam('noLoad')) {
continue;
}
$name = $fieldDefs->getName();
if (!$entityDefs->hasRelation($name)) {
continue;
}
if ($entityDefs->getRelation($name)->getType() !== Entity::HAS_ONE) {
continue;
}
$list[] = $name;
}
$this->fieldListCacheMap[$entityType] = $list;
return $list;
}
}

View File

@@ -0,0 +1,161 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Link;
use Espo\Core\Name\Field;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\ORM\EntityManager;
use Espo\ORM\Defs as OrmDefs;
use Espo\ORM\Name\Attribute;
/**
* @implements LoaderInterface<Entity>
*/
class NotJoinedLoader implements LoaderInterface
{
/** @var array<string, string[]> */
private array $fieldListCacheMap = [];
public function __construct(
private OrmDefs $ormDefs,
private EntityManager $entityManager
) {}
public function process(Entity $entity, Params $params): void
{
foreach ($this->getFieldList($entity->getEntityType()) as $field) {
$this->processItem($entity, $field);
}
}
private function processItem(Entity $entity, string $field): void
{
$nameAttribute = $field . 'Name';
$idAttribute = $field . 'Id';
$id = $entity->get($idAttribute);
if (!$id) {
$entity->set($nameAttribute, null);
return;
}
if ($entity->get($nameAttribute)) {
return;
}
$foreignEntityType = $this->ormDefs
->getEntity($entity->getEntityType())
->getRelation($field)
->getForeignEntityType();
$foreignEntity = $this->entityManager
->getRDBRepository($foreignEntityType)
->select([Attribute::ID, Field::NAME])
->where([Attribute::ID => $id])
->findOne();
if (!$foreignEntity) {
/** @noinspection PhpRedundantOptionalArgumentInspection */
$entity->set($nameAttribute, null);
return;
}
$name = $foreignEntity->get(Field::NAME);
if ($name === null) {
$foreignEntity = $this->entityManager
->getRDBRepository($foreignEntityType)
->getById($id);
if ($foreignEntity) {
$name = $foreignEntity->get(Field::NAME);
}
}
$entity->set($nameAttribute, $name);
}
/**
* @return string[]
*/
private function getFieldList(string $entityType): array
{
if (array_key_exists($entityType, $this->fieldListCacheMap)) {
return $this->fieldListCacheMap[$entityType];
}
$list = [];
$entityDefs = $this->ormDefs->getEntity($entityType);
foreach ($entityDefs->getRelationList() as $relationDefs) {
if ($relationDefs->getType() !== Entity::BELONGS_TO) {
continue;
}
// Commented to load name of leads w/o person name.
/*if (!$relationDefs->getParam('noJoin')) {
continue;
}*/
if (!$relationDefs->hasForeignEntityType()) {
continue;
}
$foreignEntityType = $relationDefs->getForeignEntityType();
if (!$this->entityManager->hasRepository($foreignEntityType)) {
continue;
}
$name = $relationDefs->getName();
if (!$entityDefs->hasAttribute($name . 'Id')) {
continue;
}
if (!$entityDefs->hasAttribute($name . 'Name')) {
continue;
}
$list[] = $name;
}
$this->fieldListCacheMap[$entityType] = $list;
return $list;
}
}

View File

@@ -0,0 +1,123 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\LinkMultiple;
use Espo\Core\ORM\Type\FieldType;
use Espo\ORM\Entity;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\ORM\Defs as OrmDefs;
/**
* @implements LoaderInterface<Entity>
*/
class ListLoader implements LoaderInterface
{
/** @var array<string, string[]> */
private array $fieldListCacheMap = [];
public function __construct(private OrmDefs $ormDefs)
{}
public function process(Entity $entity, Params $params): void
{
if (!$entity instanceof CoreEntity) {
return;
}
$entityType = $entity->getEntityType();
$select = $params->getSelect() ?? [];
if (count($select) === 0) {
return;
}
foreach ($this->getFieldList($entityType) as $field) {
if (
!in_array($field . 'Ids', $select) &&
!in_array($field . 'Names', $select)
) {
continue;
}
if ($entity->has($field . 'Ids') && $entity->has($field . 'Names')) {
continue;
}
$entity->loadLinkMultipleField($field);
}
}
/**
* @return string[]
*/
private function getFieldList(string $entityType): array
{
if (array_key_exists($entityType, $this->fieldListCacheMap)) {
return $this->fieldListCacheMap[$entityType];
}
$list = [];
$entityDefs = $this->ormDefs->getEntity($entityType);
foreach ($entityDefs->getFieldList() as $fieldDefs) {
if (
$fieldDefs->getType() !== FieldType::LINK_MULTIPLE &&
$fieldDefs->getType() !== FieldType::ATTACHMENT_MULTIPLE
) {
continue;
}
if ($fieldDefs->getParam('noLoad')) {
continue;
}
if ($fieldDefs->isNotStorable()) {
continue;
}
$name = $fieldDefs->getName();
if (!$entityDefs->hasRelation($name)) {
continue;
}
$list[] = $name;
}
$this->fieldListCacheMap[$entityType] = $list;
return $list;
}
}

View File

@@ -0,0 +1,105 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\LinkMultiple;
use Espo\Core\ORM\Type\FieldType;
use Espo\ORM\Entity;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\ORM\Defs as OrmDefs;
/**
* @implements LoaderInterface<Entity>
*/
class Loader implements LoaderInterface
{
/** @var array<string, string[]> */
private array $fieldListCacheMap = [];
public function __construct(private OrmDefs $ormDefs)
{}
public function process(Entity $entity, Params $params): void
{
if (!$entity instanceof CoreEntity) {
return;
}
$entityType = $entity->getEntityType();
foreach ($this->getFieldList($entityType) as $field) {
$entity->loadLinkMultipleField($field);
}
}
/**
* @return string[]
*/
private function getFieldList(string $entityType): array
{
if (array_key_exists($entityType, $this->fieldListCacheMap)) {
return $this->fieldListCacheMap[$entityType];
}
$list = [];
$entityDefs = $this->ormDefs->getEntity($entityType);
foreach ($entityDefs->getFieldList() as $fieldDefs) {
if (
$fieldDefs->getType() !== FieldType::LINK_MULTIPLE &&
$fieldDefs->getType() !== FieldType::ATTACHMENT_MULTIPLE
) {
continue;
}
if ($fieldDefs->getParam('noLoad')) {
continue;
}
if ($fieldDefs->isNotStorable()) {
continue;
}
$name = $fieldDefs->getName();
if (!$entityDefs->hasRelation($name)) {
continue;
}
$list[] = $name;
}
$this->fieldListCacheMap[$entityType] = $list;
return $list;
}
}

View File

@@ -0,0 +1,89 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\LinkParent;
use Espo\Core\ORM\Type\FieldType;
use Espo\ORM\Entity;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\ORM\Defs as OrmDefs;
/**
* @implements LoaderInterface<Entity>
*/
class Loader implements LoaderInterface
{
/** @var array<string, string[]> */
private array $fieldListCacheMap = [];
public function __construct(private OrmDefs $ormDefs)
{}
public function process(Entity $entity, Params $params): void
{
if (!$entity instanceof CoreEntity) {
return;
}
foreach ($this->getFieldList($entity->getEntityType()) as $field) {
$entity->loadParentNameField($field);
}
}
/**
* @return string[]
*/
private function getFieldList(string $entityType): array
{
if (array_key_exists($entityType, $this->fieldListCacheMap)) {
return $this->fieldListCacheMap[$entityType];
}
$list = [];
$entityDefs = $this->ormDefs->getEntity($entityType);
foreach ($entityDefs->getFieldList() as $fieldDefs) {
if ($fieldDefs->getType() !== FieldType::LINK_PARENT) {
continue;
}
$name = $fieldDefs->getName();
$list[] = $name;
}
$this->fieldListCacheMap[$entityType] = $list;
return $list;
}
}

View File

@@ -0,0 +1,86 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\LinkParent;
use Espo\Core\Name\Field;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
/**
* @implements LoaderInterface<Entity>
*/
class TargetLoader implements LoaderInterface
{
public function __construct(private EntityManager $entityManager)
{}
public function process(Entity $entity, Params $params): void
{
$targetType = $entity->get('targetType');
$targetId = $entity->get('targetId');
if (!$targetType || !$targetId) {
return;
}
if (!$this->entityManager->hasRepository($targetType)) {
return;
}
$query = $this->entityManager
->getQueryBuilder()
->select()
->from($targetType)
->withDeleted()
->where([
Attribute::ID => $targetId,
])
->build();
$target = $this->entityManager
->getRDBRepository($targetType)
->clone($query)
->findOne();
if (!$target) {
return;
}
if (!$target->get(Field::NAME)) {
return;
}
$entity->set('targetName', $target->get(Field::NAME));
}
}

View File

@@ -0,0 +1,163 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing;
use Espo\Core\Acl;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\Utils\FieldUtil;
use Espo\Entities\User;
use Espo\ORM\Defs;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
/**
* Processes loading special fields for list view (before output).
*/
class ListLoadProcessor
{
/** @var array<string, Loader<Entity>[]> */
private $loaderListMapCache = [];
private BindingContainer $bindingContainer;
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata,
private Acl $acl,
private User $user,
private Defs $defs,
private FieldUtil $fieldUtil,
) {
$this->bindingContainer = BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->bindInstance(Acl::class, $this->acl)
->build();
}
public function process(Entity $entity, ?Params $params = null): void
{
if (!$params) {
$params = new Params();
}
foreach ($this->getLoaderList($entity->getEntityType(), $params) as $processor) {
$processor->process($entity, $params);
}
}
/**
* @return Loader<Entity>[]
*/
private function getLoaderList(string $entityType, Params $params): array
{
if (array_key_exists($entityType, $this->loaderListMapCache)) {
return $this->loaderListMapCache[$entityType];
}
$list = [];
foreach ($this->getLoaderClassNameList($entityType, $params) as $className) {
$list[] = $this->createLoader($className);
}
$this->loaderListMapCache[$entityType] = $list;
return $list;
}
/**
* @return class-string<Loader<Entity>>[]
*/
private function getLoaderClassNameList(string $entityType, Params $params): array
{
$entityLevelList = $this->getEntityLevelClassNameList($entityType, $params);
$list = $this->metadata
->get(['app', 'fieldProcessing', 'listLoaderClassNameList']) ?? [];
$additionalList = $this->metadata
->get(['recordDefs', $entityType, 'listLoaderClassNameList']) ?? [];
$list = array_merge($list, $additionalList, $entityLevelList);
return array_values(array_unique($list));
}
/**
* @param class-string<Loader<Entity>> $className
* @return Loader<Entity>
*/
private function createLoader(string $className): Loader
{
return $this->injectableFactory->createWithBinding($className, $this->bindingContainer);
}
/**
* @return class-string<Loader<Entity>>[]
*/
private function getEntityLevelClassNameList(string $entityType, Params $params): array
{
$entityLevelList = [];
$fieldList = $this->defs->getEntity($entityType)->getFieldList();
foreach ($fieldList as $fieldDefs) {
$className = $fieldDefs->getParam('loaderClassName');
if (!$className || in_array($className, $entityLevelList)) {
continue;
}
if ($params->hasSelect()) {
$hasAttribute = false;
foreach ($this->fieldUtil->getAttributeList($entityType, $fieldDefs->getName()) as $attribute) {
if ($params->hasInSelect($attribute)) {
$hasAttribute = true;
break;
}
}
if (!$hasAttribute) {
continue;
}
}
$entityLevelList[] = $className;
}
return $entityLevelList;
}
}

View File

@@ -0,0 +1,47 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Loader\Params;
/**
* Processes loading special fields before output.
*
* @template TEntity of Entity
*/
interface Loader
{
/**
* @param TEntity $entity
*/
public function process(Entity $entity, Params $params): void;
}

View File

@@ -0,0 +1,75 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Loader;
/**
* Immutable.
*/
class Params
{
/** @var ?string[] */
private ?array $select = null;
public function __construct() {}
public function hasInSelect(string $field): bool
{
return $this->hasSelect() && in_array($field, $this->select ?? []);
}
public function hasSelect(): bool
{
return $this->select !== null;
}
/**
* @return ?string[]
*/
public function getSelect(): ?array
{
return $this->select;
}
/**
* @param ?string[] $select
*/
public function withSelect(?array $select): self
{
$obj = clone $this;
$obj->select = $select;
return $obj;
}
public static function create(): self
{
return new self();
}
}

View File

@@ -0,0 +1,125 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\MultiEnum;
use Espo\Entities\ArrayValue;
use Espo\ORM\Entity;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Repositories\ArrayValue as Repository;
use Espo\Core\FieldProcessing\Saver as SaverInterface;
use Espo\Core\FieldProcessing\Saver\Params;
use Espo\Core\ORM\EntityManager;
/**
* @implements SaverInterface<Entity>
*/
class Saver implements SaverInterface
{
private EntityManager $entityManager;
/** @var array<string, string[]> */
private $fieldListMapCache = [];
public function __construct(EntityManager $entityManager)
{
$this->entityManager = $entityManager;
}
public function process(Entity $entity, Params $params): void
{
foreach ($this->getFieldList($entity->getEntityType()) as $name) {
$this->processItem($entity, $name);
}
}
private function processItem(Entity $entity, string $name): void
{
if (!$entity->has($name)) {
return;
}
if (!$entity->isAttributeChanged($name)) {
return;
}
/** @var Repository $repository */
$repository = $this->entityManager->getRepository(ArrayValue::ENTITY_TYPE);
assert($entity instanceof CoreEntity);
$repository->storeEntityAttribute($entity, $name);
}
/**
* @return string[]
*/
private function getFieldList(string $entityType): array
{
if (array_key_exists($entityType, $this->fieldListMapCache)) {
return $this->fieldListMapCache[$entityType];
}
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entityType);
$list = [];
foreach ($entityDefs->getAttributeNameList() as $name) {
$defs = $entityDefs->getAttribute($name);
if ($defs->getType() !== Entity::JSON_ARRAY) {
continue;
}
if (!$defs->getParam('storeArrayValues')) {
continue;
}
if (
$entityDefs->hasField($name) &&
$entityDefs->getField($name)->getParam('doNotStoreArrayValues')
) {
continue;
}
if ($defs->isNotStorable()) {
continue;
}
$list[] = $name;
}
$this->fieldListMapCache[$entityType] = $list;
return $list;
}
}

View File

@@ -0,0 +1,186 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\NextNumber;
use Espo\Core\Exceptions\Error;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\ORM\Type\FieldType;
use Espo\Entities\NextNumber;
use Espo\Core\ORM\Entity;
use Espo\Core\ORM\EntityManager;
use Espo\Core\Utils\Metadata;
use const STR_PAD_LEFT;
class BeforeSaveProcessor
{
/** @var array<string, string[]> */
private $fieldListMapCache = [];
public function __construct(
private Metadata $metadata,
private EntityManager $entityManager,
) {}
/**
* For an existing record.
* @throws Error
*/
public function processPopulate(Entity $entity, string $field): void
{
$fieldList = $this->getFieldList($entity->getEntityType());
if (!in_array($field, $fieldList)) {
throw new Error("Bad field.");
}
$this->processItem($entity, $field, [], true);
}
/**
* @param array<string, mixed> $options
*/
public function process(Entity $entity, array $options): void
{
$fieldList = $this->getFieldList($entity->getEntityType());
foreach ($fieldList as $field) {
$this->processItem($entity, $field, $options);
}
}
/**
* @param array<string, mixed> $options
*/
private function processItem(Entity $entity, string $field, array $options, bool $populate = false): void
{
if (!empty($options[SaveOption::IMPORT]) && $entity->has($field)) {
return;
}
if (!$entity->isNew()) {
if ($entity->isAttributeChanged($field)) {
$entity->set($field, $entity->getFetched($field));
}
if (!$populate) {
return;
}
}
$this->entityManager->getTransactionManager()->run(function () use ($entity, $field) {
$nextNumber = $this->getNextNumberEntity($entity, $field);
$entity->set($field, $this->composeNumberStringValue($nextNumber));
$nextNumber->setNumberValue($this->prepareNextNumberValue($nextNumber));
$this->entityManager->saveEntity($nextNumber);
});
}
private function composeNumberStringValue(NextNumber $nextNumber): string
{
$entityType = $nextNumber->getTargetEntityType();
$fieldName = $nextNumber->getTargetFieldName();
$value = $nextNumber->getNumberValue();
$prefix = $this->metadata->get(['entityDefs', $entityType, 'fields', $fieldName, 'prefix'], '');
$padLength = $this->metadata->get(['entityDefs', $entityType, 'fields', $fieldName, 'padLength'], 0);
return $prefix . str_pad(strval($value), $padLength, '0', STR_PAD_LEFT);
}
/**
* @return string[]
*/
private function getFieldList(string $entityType): array
{
if (array_key_exists($entityType, $this->fieldListMapCache)) {
return $this->fieldListMapCache[$entityType];
}
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entityType);
$list = [];
foreach ($entityDefs->getFieldNameList() as $name) {
$defs = $entityDefs->getField($name);
if ($defs->getType() !== FieldType::NUMBER) {
continue;
}
$list[] = $name;
}
$this->fieldListMapCache[$entityType] = $list;
return $list;
}
private function prepareNextNumberValue(NextNumber $nextNumber): int
{
$value = $nextNumber->getNumberValue();
if (!$value) {
$value = 1;
}
$value++;
return $value;
}
private function getNextNumberEntity(Entity $entity, string $field): NextNumber
{
$nextNumber = $this->entityManager
->getRDBRepositoryByClass(NextNumber::class)
->where([
'fieldName' => $field,
'entityType' => $entity->getEntityType(),
])
->forUpdate()
->findOne();
if (!$nextNumber) {
$nextNumber = $this->entityManager->getRDBRepositoryByClass(NextNumber::class)->getNew();
$nextNumber
->setTargetEntityType($entity->getEntityType())
->setTargetFieldName($field);
}
return $nextNumber;
}
}

View File

@@ -0,0 +1,72 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\PhoneNumber;
use Espo\Repositories\PhoneNumber as Repository;
use Espo\ORM\Entity;
use Espo\Entities\PhoneNumber;
use Espo\Entities\User;
use Espo\Core\AclManager;
use Espo\Core\ORM\EntityManager;
class AccessChecker
{
public function __construct(
private EntityManager $entityManager,
private AclManager $aclManager
) {}
public function checkEdit(User $user, PhoneNumber $phoneNumber, Entity $excludeEntity): bool
{
/** @var Repository $repository */
$repository = $this->entityManager->getRepository('PhoneNumber');
$entityWithSameNumberList = $repository->getEntityListByPhoneNumberId($phoneNumber->getId(), $excludeEntity);
foreach ($entityWithSameNumberList as $e) {
if ($this->aclManager->checkEntityEdit($user, $e)) {
continue;
}
if (
$e instanceof User &&
$e->isPortal() &&
$excludeEntity->getEntityType() === 'Contact' &&
$e->get('contactId') === $excludeEntity->getId()
) {
continue;
}
return false;
}
return true;
}
}

View File

@@ -0,0 +1,70 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\PhoneNumber;
use Espo\Core\ORM\Type\FieldType;
use Espo\Entities\PhoneNumber;
use Espo\ORM\Entity;
use Espo\Repositories\PhoneNumber as Repository;
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\ORM\EntityManager;
use Espo\ORM\Defs as OrmDefs;
/**
* @implements LoaderInterface<Entity>
*/
class Loader implements LoaderInterface
{
public function __construct(private OrmDefs $ormDefs, private EntityManager $entityManager)
{}
public function process(Entity $entity, Params $params): void
{
$entityDefs = $this->ormDefs->getEntity($entity->getEntityType());
if (!$entityDefs->hasField('phoneNumber')) {
return;
}
if ($entityDefs->getField('phoneNumber')->getType() !== FieldType::PHONE) {
return;
}
/** @var Repository $repository */
$repository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE);
$phoneNumberData = $repository->getPhoneNumberData($entity);
$entity->set('phoneNumberData', $phoneNumberData);
$entity->setFetched('phoneNumberData', $phoneNumberData);
}
}

View File

@@ -0,0 +1,573 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\PhoneNumber;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\ORM\Type\FieldType;
use Espo\Entities\PhoneNumber;
use Espo\ORM\Name\Attribute;
use Espo\Repositories\PhoneNumber as PhoneNumberRepository;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Mapper\BaseMapper;
use Espo\Core\ApplicationState;
use Espo\Core\FieldProcessing\Saver as SaverInterface;
use Espo\Core\FieldProcessing\Saver\Params;
use Espo\Core\Utils\Metadata;
/**
* @implements SaverInterface<Entity>
*/
class Saver implements SaverInterface
{
private const ATTR_PHONE_NUMBER = 'phoneNumber';
private const ATTR_PHONE_NUMBER_DATA = 'phoneNumberData';
private const ATTR_PHONE_NUMBER_IS_OPTED_OUT = 'phoneNumberIsOptedOut';
private const ATTR_PHONE_NUMBER_IS_INVALID = 'phoneNumberIsInvalid';
public function __construct(
private EntityManager $entityManager,
private ApplicationState $applicationState,
private AccessChecker $accessChecker,
private Metadata $metadata
) {}
public function process(Entity $entity, Params $params): void
{
$entityType = $entity->getEntityType();
$defs = $this->entityManager->getDefs()->getEntity($entityType);
if (!$defs->hasField(self::ATTR_PHONE_NUMBER)) {
return;
}
if ($defs->getField(self::ATTR_PHONE_NUMBER)->getType() !== FieldType::PHONE) {
return;
}
$phoneNumberData = null;
if ($entity->has(self::ATTR_PHONE_NUMBER_DATA)) {
$phoneNumberData = $entity->get(self::ATTR_PHONE_NUMBER_DATA);
}
if ($phoneNumberData !== null && $entity->isAttributeChanged(self::ATTR_PHONE_NUMBER_DATA)) {
$this->storeData($entity);
return;
}
if ($entity->has(self::ATTR_PHONE_NUMBER)) {
$this->storePrimary($entity);
}
}
private function storeData(Entity $entity): void
{
if (!$entity->has(self::ATTR_PHONE_NUMBER_DATA)) {
return;
}
$phoneNumberValue = $entity->get(self::ATTR_PHONE_NUMBER);
if (is_string($phoneNumberValue)) {
$phoneNumberValue = trim($phoneNumberValue);
}
$phoneNumberData = $entity->get(self::ATTR_PHONE_NUMBER_DATA);
if (!is_array($phoneNumberData)) {
return;
}
$noPrimary = array_filter($phoneNumberData, fn ($item) => !empty($item->primary)) === [];
if ($noPrimary && $phoneNumberData !== []) {
$phoneNumberData[0]->primary = true;
}
$keyList = [];
$keyPreviousList = [];
$previousPhoneNumberData = [];
if (!$entity->isNew()) {
/** @var PhoneNumberRepository $repository */
$repository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE);
$previousPhoneNumberData = $repository->getPhoneNumberData($entity);
}
$hash = (object) [];
$hashPrevious = (object) [];
foreach ($phoneNumberData as $row) {
$key = trim($row->phoneNumber);
if (empty($key)) {
continue;
}
$type = $row->type ??
$this->metadata
->get(['entityDefs', $entity->getEntityType(), 'fields', 'phoneNumber', 'defaultType']);
$hash->$key = [
'primary' => !empty($row->primary),
'type' => $type,
'optOut' => !empty($row->optOut),
'invalid' => !empty($row->invalid),
];
$keyList[] = $key;
}
if (
$entity->has(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT) && (
$entity->isNew() ||
(
$entity->hasFetched(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT) &&
$entity->isAttributeChanged(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT)
)
) &&
$phoneNumberValue
) {
$key = $phoneNumberValue;
if (isset($hash->$key)) {
$hash->{$key}['optOut'] = (bool) $entity->get(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT);
}
}
if (
$entity->has(self::ATTR_PHONE_NUMBER_IS_INVALID) && (
$entity->isNew() ||
(
$entity->hasFetched(self::ATTR_PHONE_NUMBER_IS_INVALID) &&
$entity->isAttributeChanged(self::ATTR_PHONE_NUMBER_IS_INVALID)
)
) &&
$phoneNumberValue
) {
$key = $phoneNumberValue;
if (isset($hash->$key)) {
$hash->{$key}['invalid'] = (bool) $entity->get(self::ATTR_PHONE_NUMBER_IS_INVALID);
}
}
foreach ($previousPhoneNumberData as $row) {
$key = $row->phoneNumber;
if (empty($key)) {
continue;
}
$hashPrevious->$key = [
'primary' => (bool) $row->primary,
'type' => $row->type,
'optOut' => (bool) $row->optOut,
'invalid' => (bool) $row->invalid,
];
$keyPreviousList[] = $key;
}
$primary = null;
$toCreateList = [];
$toUpdateList = [];
$toRemoveList = [];
$revertData = [];
foreach ($keyList as $key) {
$new = true;
$changed = false;
if ($hash->{$key}['primary']) {
$primary = $key;
}
if (property_exists($hashPrevious, $key)) {
$new = false;
$changed =
$hash->{$key}['type'] != $hashPrevious->{$key}['type'] ||
$hash->{$key}['optOut'] != $hashPrevious->{$key}['optOut'] ||
$hash->{$key}['invalid'] != $hashPrevious->{$key}['invalid'];
if (
$hash->{$key}['primary'] &&
$hash->{$key}['primary'] === $hashPrevious->{$key}['primary']
) {
$primary = null;
}
}
if ($new) {
$toCreateList[] = $key;
}
if ($changed) {
$toUpdateList[] = $key;
}
}
foreach ($keyPreviousList as $key) {
if (!property_exists($hash, $key)) {
$toRemoveList[] = $key;
}
}
foreach ($toRemoveList as $number) {
$phoneNumber = $this->getByNumber($number);
if (!$phoneNumber) {
continue;
}
$delete = $this->entityManager->getQueryBuilder()
->delete()
->from('EntityPhoneNumber')
->where([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'phoneNumberId' => $phoneNumber->getId(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($delete);
}
foreach ($toUpdateList as $number) {
$phoneNumber = $this->getByNumber($number);
if ($phoneNumber) {
$skipSave = $this->checkChangeIsForbidden($phoneNumber, $entity);
if (!$skipSave) {
$phoneNumber->set([
'type' => $hash->{$number}['type'],
'optOut' => $hash->{$number}['optOut'],
'invalid' => $hash->{$number}['invalid'],
]);
$this->entityManager->saveEntity($phoneNumber);
} else {
$revertData[$number] = [
'type' => $phoneNumber->get('type'),
'optOut' => $phoneNumber->get('optOut'),
'invalid' => $phoneNumber->get('invalid'),
];
}
}
}
foreach ($toCreateList as $number) {
$phoneNumber = $this->getByNumber($number);
if (!$phoneNumber) {
$phoneNumber = $this->entityManager->getNewEntity(PhoneNumber::ENTITY_TYPE);
$phoneNumber->set([
'name' => $number,
'type' => $hash->{$number}['type'],
'optOut' => $hash->{$number}['optOut'],
'invalid' => $hash->{$number}['invalid'],
]);
$this->entityManager->saveEntity($phoneNumber);
} else {
$skipSave = $this->checkChangeIsForbidden($phoneNumber, $entity);
if (!$skipSave) {
if (
$phoneNumber->get('type') != $hash->{$number}['type'] ||
$phoneNumber->get('optOut') != $hash->{$number}['optOut'] ||
$phoneNumber->get('invalid') != $hash->{$number}['invalid']
) {
$phoneNumber->set([
'type' => $hash->{$number}['type'],
'optOut' => $hash->{$number}['optOut'],
'invalid' => $hash->{$number}['invalid'],
]);
$this->entityManager->saveEntity($phoneNumber);
}
} else {
$revertData[$number] = [
'type' => $phoneNumber->getType(),
'optOut' => $phoneNumber->isOptedOut(),
'invalid' => $phoneNumber->isInvalid(),
];
}
}
$entityPhoneNumber = $this->entityManager->getNewEntity('EntityPhoneNumber');
$entityPhoneNumber->set([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'phoneNumberId' => $phoneNumber->getId(),
'primary' => $number === $primary,
Attribute::DELETED => false,
]);
/** @var BaseMapper $mapper */
$mapper = $this->entityManager->getMapper();
$mapper->insertOnDuplicateUpdate($entityPhoneNumber, [
'primary',
Attribute::DELETED,
]);
}
if ($primary) {
$phoneNumber = $this->getByNumber($primary);
$entity->set(self::ATTR_PHONE_NUMBER, $primary);
if ($phoneNumber) {
$update1 = $this->entityManager
->getQueryBuilder()
->update()
->in('EntityPhoneNumber')
->set(['primary' => false])
->where([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'primary' => true,
Attribute::DELETED => false,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update1);
$update2 = $this->entityManager
->getQueryBuilder()
->update()
->in('EntityPhoneNumber')
->set(['primary' => true])
->where([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'phoneNumberId' => $phoneNumber->getId(),
Attribute::DELETED => false,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update2);
}
}
if (!empty($revertData)) {
foreach ($phoneNumberData as $row) {
if (empty($revertData[$row->phoneNumber])) {
continue;
}
$row->type = $revertData[$row->phoneNumber]['type'];
$row->optOut = $revertData[$row->phoneNumber]['optOut'];
$row->invalid = $revertData[$row->phoneNumber]['invalid'];
}
$entity->set(self::ATTR_PHONE_NUMBER_DATA, $phoneNumberData);
}
}
private function storePrimary(Entity $entity): void
{
if (!$entity->has(self::ATTR_PHONE_NUMBER)) {
return;
}
$phoneNumberValue = trim($entity->get(self::ATTR_PHONE_NUMBER) ?? '');
$entityRepository = $this->entityManager->getRDBRepository($entity->getEntityType());
if (!empty($phoneNumberValue)) {
if ($phoneNumberValue !== $entity->getFetched(self::ATTR_PHONE_NUMBER)) {
$this->storePrimaryNotEmpty($phoneNumberValue, $entity);
return;
}
if (
$entity->has(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT) &&
(
$entity->isNew() ||
(
$entity->hasFetched(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT) &&
$entity->isAttributeChanged(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT)
)
)
) {
$this->markNumberOptedOut($phoneNumberValue, (bool) $entity->get(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT));
}
if (
$entity->has(self::ATTR_PHONE_NUMBER_IS_INVALID) &&
(
$entity->isNew() ||
(
$entity->hasFetched(self::ATTR_PHONE_NUMBER_IS_INVALID) &&
$entity->isAttributeChanged(self::ATTR_PHONE_NUMBER_IS_INVALID)
)
)
) {
$this->markNumberInvalid($phoneNumberValue, (bool) $entity->get(self::ATTR_PHONE_NUMBER_IS_INVALID));
}
return;
}
$phoneNumberValueOld = $entity->getFetched(self::ATTR_PHONE_NUMBER);
if (!empty($phoneNumberValueOld)) {
$phoneNumberOld = $this->getByNumber($phoneNumberValueOld);
if ($phoneNumberOld) {
$entityRepository
->getRelation($entity, 'phoneNumbers')
->unrelate($phoneNumberOld, [SaveOption::SKIP_HOOKS => true]);
}
}
}
private function getByNumber(string $number): ?PhoneNumber
{
/** @var PhoneNumberRepository $repository */
$repository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE);
return $repository->getByNumber($number);
}
private function markNumberOptedOut(string $number, bool $isOptedOut = true): void
{
/** @var PhoneNumberRepository $repository */
$repository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE);
$repository->markNumberOptedOut($number, $isOptedOut);
}
private function markNumberInvalid(string $number, bool $isInvalid = true): void
{
/** @var PhoneNumberRepository $repository */
$repository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE);
$repository->markNumberInvalid($number, $isInvalid);
}
private function checkChangeIsForbidden(PhoneNumber $phoneNumber, Entity $entity): bool
{
if (!$this->applicationState->hasUser()) {
return true;
}
$user = $this->applicationState->getUser();
// @todo Check if not modified by system.
return !$this->accessChecker->checkEdit($user, $phoneNumber, $entity);
}
private function storePrimaryNotEmpty(string $phoneNumberValue, Entity $entity): void
{
$entityRepository = $this->entityManager->getRDBRepository($entity->getEntityType());
$phoneNumberNew = $this->entityManager
->getRDBRepository(PhoneNumber::ENTITY_TYPE)
->where([
'name' => $phoneNumberValue,
])
->findOne();
if (!$phoneNumberNew) {
/** @var PhoneNumber $phoneNumberNew */
$phoneNumberNew = $this->entityManager->getNewEntity(PhoneNumber::ENTITY_TYPE);
$phoneNumberNew->setNumber($phoneNumberValue);
if ($entity->has(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT)) {
$phoneNumberNew->setOptedOut((bool)$entity->get(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT));
}
if ($entity->has(self::ATTR_PHONE_NUMBER_IS_INVALID)) {
$phoneNumberNew->setInvalid((bool)$entity->get(self::ATTR_PHONE_NUMBER_IS_INVALID));
}
$defaultType = $this->metadata
->get("entityDefs.{$entity->getEntityType()}.fields.phoneNumber.defaultType");
$phoneNumberNew->setType($defaultType);
$this->entityManager->saveEntity($phoneNumberNew);
}
$phoneNumberValueOld = $entity->getFetched(self::ATTR_PHONE_NUMBER);
if (!empty($phoneNumberValueOld)) {
$phoneNumberOld = $this->getByNumber($phoneNumberValueOld);
if ($phoneNumberOld) {
$entityRepository
->getRelation($entity, 'phoneNumbers')
->unrelate($phoneNumberOld, [SaveOption::SKIP_HOOKS => true]);
}
}
$entityRepository
->getRelation($entity, 'phoneNumbers')
->relate($phoneNumberNew, null, [SaveOption::SKIP_HOOKS => true]);
if ($entity->has(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT)) {
$this->markNumberOptedOut($phoneNumberValue, (bool)$entity->get(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT));
}
if ($entity->has(self::ATTR_PHONE_NUMBER_IS_INVALID)) {
$this->markNumberInvalid($phoneNumberValue, (bool)$entity->get(self::ATTR_PHONE_NUMBER_IS_INVALID));
}
$update = $this->entityManager
->getQueryBuilder()
->update()
->in('EntityPhoneNumber')
->set(['primary' => true])
->where([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'phoneNumberId' => $phoneNumberNew->getId(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
}
}

View File

@@ -0,0 +1,146 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing;
use Espo\Core\Acl;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Entities\User;
use Espo\ORM\Defs;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
/**
* Processes loading special fields (before output).
*/
class ReadLoadProcessor
{
/** @var array<string, Loader<Entity>[]> */
private array $loaderListMapCache = [];
private BindingContainer $bindingContainer;
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata,
private Acl $acl,
private User $user,
private Defs $defs,
) {
$this->bindingContainer = BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->bindInstance(Acl::class, $this->acl)
->build();
}
public function process(Entity $entity, ?Params $params = null): void
{
if (!$params) {
$params = new Params();
}
foreach ($this->getLoaderList($entity->getEntityType()) as $processor) {
$processor->process($entity, $params);
}
}
/**
* @return Loader<Entity>[]
*/
private function getLoaderList(string $entityType): array
{
if (array_key_exists($entityType, $this->loaderListMapCache)) {
return $this->loaderListMapCache[$entityType];
}
$list = [];
foreach ($this->getLoaderClassNameList($entityType) as $className) {
$list[] = $this->createLoader($className);
}
$this->loaderListMapCache[$entityType] = $list;
return $list;
}
/**
* @return class-string<Loader<Entity>>[]
*/
private function getLoaderClassNameList(string $entityType): array
{
$entityLevelList = $this->getEntityLevelClassNameList($entityType);
$list = $this->metadata
->get(['app', 'fieldProcessing', 'readLoaderClassNameList']) ?? [];
$additionalList = $this->metadata
->get(['recordDefs', $entityType, 'readLoaderClassNameList']) ?? [];
/** @var class-string<Loader<Entity>>[] $list */
$list = array_merge($list, $additionalList, $entityLevelList);
return array_values(array_unique($list));
}
/**
* @param class-string<Loader<Entity>> $className
* @return Loader<Entity>
*/
private function createLoader(string $className): Loader
{
return $this->injectableFactory->createWithBinding($className, $this->bindingContainer);
}
/**
* @return class-string<Loader<Entity>>[]
*/
private function getEntityLevelClassNameList(string $entityType): array
{
$entityLevelList = [];
$fieldList = $this->defs->getEntity($entityType)->getFieldList();
foreach ($fieldList as $fieldDefs) {
$className = $fieldDefs->getParam('loaderClassName');
if (!$className || in_array($className, $entityLevelList)) {
continue;
}
$entityLevelList[] = $className;
}
return $entityLevelList;
}
}

View File

@@ -0,0 +1,260 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Relation;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\FieldProcessing\Saver\Params;
use Espo\Core\ORM\EntityManager;
use Espo\Core\ORM\Repository\Option\SaveContext;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\ORM\Defs\Params\RelationParam;
use RuntimeException;
/**
* Saves a link-multiple field or has-many relation set in a link stub attribute.
* In case of a stab attribute, only processed for a new record.
*/
class LinkMultipleSaver
{
private const RELATE_OPTION = 'linkMultiple';
public function __construct(private EntityManager $entityManager)
{}
public function process(CoreEntity $entity, string $name, Params $params): void
{
$idListAttribute = $name . 'Ids';
$columnsAttribute = $name . 'Columns';
if (
!$entity->isNew() &&
!$entity->hasLinkMultipleField($name)
) {
$entity->clear($idListAttribute);
return;
}
$isChanged = $entity->isAttributeChanged($idListAttribute) || $entity->isAttributeChanged($columnsAttribute);
if (!$isChanged) {
return;
}
$defs = $this->entityManager->getDefs()->getEntity($entity->getEntityType());
$skipCreate = $params->getOption('skipLinkMultipleCreate') ?? false;
$skipRemove = $params->getOption('skipLinkMultipleRemove') ?? false;
$skipUpdate = $params->getOption('skipLinkMultipleUpdate') ?? false;
$skipHooks = $params->getOption('skipLinkMultipleHooks') ?? false;
if ($entity->isNew()) {
$skipRemove = true;
$skipUpdate = true;
}
if ($entity->has($idListAttribute)) {
$specifiedIdList = $entity->get($idListAttribute);
} else if ($entity->has($columnsAttribute)) {
$skipRemove = true;
$specifiedIdList = array_keys(
get_object_vars(
$entity->get($columnsAttribute) ?? (object) []
)
);
} else {
return;
}
if (!is_array($specifiedIdList)) {
return;
}
$toRemoveIdList = [];
$existingIdList = [];
$toUpdateIdList = [];
$toCreateIdList = [];
$existingColumnsData = (object) [];
$columns = null;
if ($defs->hasField($name)) {
$columns = $defs->getField($name)->getParam('columns');
}
$allColumns = $columns;
if (is_array($columns)) {
$additionalColumns = $defs->getRelation($name)->getParam(RelationParam::ADDITIONAL_COLUMNS) ?? [];
foreach ($columns as $column => $field) {
if (!array_key_exists($column, $additionalColumns)) {
unset($columns[$column]);
}
}
}
$repository = $this->entityManager->getRDBRepository($entity->getEntityType());
$columnData = !empty($columns) ?
$entity->get($columnsAttribute) :
null;
if (!$skipRemove || !$skipUpdate) {
$foreignEntityList = $repository->getRelation($entity, $name)->find();
foreach ($foreignEntityList as $foreignEntity) {
$existingIdList[] = $foreignEntity->getId();
if (empty($allColumns)) {
continue;
}
$data = (object) [];
$foreignId = $foreignEntity->getId();
foreach ($allColumns as $columnName => $columnField) {
$data->$columnName = $foreignEntity->get($columnField);
}
$existingColumnsData->$foreignId = $data;
if (!$entity->isNew()) {
$entity->setFetched($columnsAttribute, $existingColumnsData);
}
}
}
if (!$entity->isNew()) {
if ($entity->has($idListAttribute) && !$entity->hasFetched($idListAttribute)) {
$entity->setFetched($idListAttribute, $existingIdList);
}
if ($entity->has($columnsAttribute) && !empty($allColumns)) {
$entity->setFetched($columnsAttribute, $existingColumnsData);
}
}
foreach ($existingIdList as $id) {
if (!in_array($id, $specifiedIdList)) {
if (!$skipRemove) {
$toRemoveIdList[] = $id;
}
continue;
}
if ($skipUpdate || empty($columns)) {
continue;
}
foreach ($columns as $columnName => $columnField) {
if (!isset($columnData->$id) || !is_object($columnData->$id)) {
continue;
}
if (
property_exists($columnData->$id, $columnName) &&
(
!property_exists($existingColumnsData->$id, $columnName) ||
$columnData->$id->$columnName !== $existingColumnsData->$id->$columnName
)
) {
$toUpdateIdList[] = $id;
}
}
}
if (!$skipCreate) {
foreach ($specifiedIdList as $id) {
if (!in_array($id, $existingIdList)) {
$toCreateIdList[] = $id;
}
if (!is_string($id)) {
throw new RuntimeException("Non-string ID in link-multiple.");
}
if ($id === '') {
throw new RuntimeException("An entity ID value in link-multiple.");
}
}
}
$saveContext = SaveContext::obtainFromRawOptions($params->getRawOptions());
foreach ($toCreateIdList as $id) {
$data = null;
if (is_array($columns) && isset($columnData->$id)) {
$data = (array) $columnData->$id;
foreach ($data as $column => $v) {
if (!array_key_exists($column, $columns)) {
unset($data[$column]);
}
}
}
$repository->getRelation($entity, $name)->relateById($id, $data, [
SaveOption::SKIP_HOOKS => $skipHooks,
SaveOption::SILENT => $entity->isNew(),
self::RELATE_OPTION => $entity->hasLinkMultipleField($name),
SaveContext::NAME => $saveContext,
]);
}
foreach ($toRemoveIdList as $id) {
$repository->getRelation($entity, $name)->unrelateById($id, [
SaveOption::SKIP_HOOKS => $skipHooks,
self::RELATE_OPTION => $entity->hasLinkMultipleField($name),
SaveContext::NAME => $saveContext,
]);
}
foreach ($toUpdateIdList as $id) {
$data = (array) $columnData->$id;
if (is_array($columns)) {
foreach ($data as $column => $v) {
if (!array_key_exists($column, $columns)) {
unset($data[$column]);
}
}
}
$repository->getRelation($entity, $name)->updateColumnsById($id, $data);
}
}
}

View File

@@ -0,0 +1,357 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Relation;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Saver as SaverInterface;
use Espo\Core\FieldProcessing\Saver\Params;
use Espo\Core\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Repository\Option\SaveOption;
/**
* @implements SaverInterface<Entity>
*/
class Saver implements SaverInterface
{
private EntityManager $entityManager;
private LinkMultipleSaver $linkMultipleSaver;
/** @var array<string, string[]> */
private $manyRelationListMapCache = [];
/** @var array<string, string[]> */
private $hasOneRelationListMapCache = [];
/** @var array<string, string[]> */
private $belongsToHasOneRelationListMapCache = [];
public function __construct(
EntityManager $entityManager,
LinkMultipleSaver $linkMultipleSaver
) {
$this->entityManager = $entityManager;
$this->linkMultipleSaver = $linkMultipleSaver;
}
public function process(Entity $entity, Params $params): void
{
$this->processMany($entity, $params);
$this->processHasOne($entity);
$this->processBelongsToHasOne($entity);
}
private function processMany(Entity $entity, Params $params): void
{
$entityType = $entity->getEntityType();
foreach ($this->getManyRelationList($entityType) as $name) {
$this->processManyItem($entity, $name, $params);
}
}
private function processManyItem(Entity $entity, string $name, Params $params): void
{
$idsAttribute = $name . 'Ids';
$columnsAttribute = $name . 'Columns';
if (!$entity->has($idsAttribute) && !$entity->has($columnsAttribute)) {
return;
}
if (!$entity instanceof CoreEntity) {
return;
}
$this->linkMultipleSaver->process($entity, $name, $params);
}
private function processHasOne(Entity $entity): void
{
$entityType = $entity->getEntityType();
foreach ($this->getHasOneRelationList($entityType) as $name) {
$this->processHasOneItem($entity, $name);
}
}
private function processHasOneItem(Entity $entity, string $name): void
{
$entityType = $entity->getEntityType();
$idAttribute = $name . 'Id';
if (!$entity->has($idAttribute) || !$entity->isAttributeChanged($idAttribute)) {
return;
}
/** @var ?string $id */
$id = $entity->get($idAttribute);
$defs = $this->entityManager->getDefs()->getEntity($entityType);
$relationDefs = $defs->getRelation($name);
$foreignKey = $relationDefs->getForeignKey();
$foreignEntityType = $relationDefs->getForeignEntityType();
$previous = $this->entityManager
->getRDBRepository($foreignEntityType)
->select([Attribute::ID])
->where([$foreignKey => $entity->getId()])
->findOne();
if (!$entity->isNew() && !$entity->hasFetched($idAttribute)) {
$entity->setFetched($idAttribute, $previous ? $previous->getId() : null);
}
if ($previous) {
if (!$id) {
$this->entityManager
->getRelation($entity, $name)
->unrelate($previous);
return;
}
if ($previous->getId() === $id) {
return;
}
}
if (!$id) {
return;
}
$this->entityManager
->getRelation($entity, $name)
->relateById($id);
}
private function processBelongsToHasOne(Entity $entity): void
{
$entityType = $entity->getEntityType();
foreach ($this->getBelongsToHasOneRelationList($entityType) as $name) {
$this->processBelongsToHasOneItem($entity, $name);
}
}
private function processBelongsToHasOneItem(Entity $entity, string $name): void
{
$entityType = $entity->getEntityType();
$idAttribute = $name . 'Id';
if (!$entity->get($idAttribute)) {
return;
}
if (!$entity->isAttributeChanged($idAttribute)) {
return;
}
$anotherEntity = $this->entityManager
->getRDBRepository($entityType)
->select([Attribute::ID])
->where([
$idAttribute => $entity->get($idAttribute),
Attribute::ID . '!=' => $entity->getId(),
])
->findOne();
if (!$anotherEntity) {
return;
}
/** @noinspection PhpRedundantOptionalArgumentInspection */
$anotherEntity->set($idAttribute, null);
$this->entityManager->saveEntity($anotherEntity, [
SaveOption::SKIP_ALL => true,
]);
}
/**
* @return string[]
*/
private function getManyRelationList(string $entityType): array
{
if (array_key_exists($entityType, $this->manyRelationListMapCache)) {
return $this->manyRelationListMapCache[$entityType];
}
$typeList = [
Entity::HAS_MANY,
Entity::MANY_MANY,
Entity::HAS_CHILDREN,
];
$defs = $this->entityManager->getDefs()->getEntity($entityType);
$list = [];
foreach ($defs->getRelationNameList() as $name) {
$type = $defs->getRelation($name)->getType();
if (!in_array($type, $typeList)) {
continue;
}
$idsAttribute = $name . 'Ids';
if (!$defs->hasAttribute($idsAttribute)) {
continue;
}
$attributeDefs = $defs->getAttribute($idsAttribute);
if (
!$attributeDefs->getParam(AttributeParam::IS_LINK_MULTIPLE_ID_LIST) &&
!$attributeDefs->getParam(AttributeParam::IS_LINK_STUB)
) {
continue;
}
if ($defs->hasField($name) && $defs->getField($name)->getParam('noSave')) {
continue;
}
$list[] = $name;
}
$this->manyRelationListMapCache[$entityType] = $list;
return $list;
}
/**
* @return string[]
*/
private function getHasOneRelationList(string $entityType): array
{
if (array_key_exists($entityType, $this->hasOneRelationListMapCache)) {
return $this->hasOneRelationListMapCache[$entityType];
}
$ormDefs = $this->entityManager->getDefs();
$defs = $ormDefs->getEntity($entityType);
$list = [];
foreach ($defs->getRelationNameList() as $name) {
$relationDefs = $defs->getRelation($name);
$type = $relationDefs->getType();
if ($type !== Entity::HAS_ONE) {
continue;
}
if (!$relationDefs->hasForeignEntityType()) {
continue;
}
if (!$relationDefs->hasForeignKey()) {
continue;
}
if (!$defs->hasAttribute($name . 'Id')) {
continue;
}
if ($defs->hasField($name) && $defs->getField($name)->getParam('noSave')) {
continue;
}
$list[] = $name;
}
$this->hasOneRelationListMapCache[$entityType] = $list;
return $list;
}
/**
* @return string[]
*/
private function getBelongsToHasOneRelationList(string $entityType): array
{
if (array_key_exists($entityType, $this->belongsToHasOneRelationListMapCache)) {
return $this->belongsToHasOneRelationListMapCache[$entityType];
}
$ormDefs = $this->entityManager->getDefs();
$defs = $ormDefs->getEntity($entityType);
$list = [];
foreach ($defs->getRelationNameList() as $name) {
$relationDefs = $defs->getRelation($name);
$type = $relationDefs->getType();
if ($type !== Entity::BELONGS_TO) {
continue;
}
if (!$relationDefs->hasForeignRelationName()) {
continue;
}
if (!$relationDefs->hasForeignEntityType()) {
continue;
}
if (!$defs->hasAttribute($name . 'Id')) {
continue;
}
$foreignEntityType = $relationDefs->getForeignEntityType();
$foreignRelationName = $relationDefs->getForeignRelationName();
$foreignType = $ormDefs
->getEntity($foreignEntityType)
->tryGetRelation($foreignRelationName)
?->getType();
if ($foreignType !== Entity::HAS_ONE) {
continue;
}
$list[] = $name;
}
$this->belongsToHasOneRelationListMapCache[$entityType] = $list;
return $list;
}
}

View File

@@ -0,0 +1,97 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Reminder;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Reminder;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\ORM\EntityManager;
/**
* @internal This class should not be removed as it's used by custom entities.
* @implements LoaderInterface<Entity>
*/
class Loader implements LoaderInterface
{
public function __construct(
private EntityManager $entityManager,
private User $user
) {}
public function process(Entity $entity, Params $params): void
{
$hasReminder = $this->entityManager
->getDefs()
->getEntity($entity->getEntityType())
->hasField('reminders');
if (!$hasReminder) {
return;
}
if ($params->hasSelect() && !$params->hasInSelect('reminders')) {
return;
}
$entity->set('reminders', $this->fetchReminderDataList($entity));
}
/**
* @return object{seconds: int, type: string}[]
*/
private function fetchReminderDataList(Entity $entity): array
{
$list = [];
/** @var iterable<Reminder> $collection */
$collection = $this->entityManager
->getRDBRepository(Reminder::ENTITY_TYPE)
->select(['seconds', 'type'])
->where([
'entityType' => $entity->getEntityType(),
'entityId' => $entity->getId(),
'userId' => $this->user->getId(),
])
->distinct()
->order('seconds')
->find();
foreach ($collection as $reminder) {
$list[] = (object) [
'seconds' => $reminder->getSeconds(),
'type' => $reminder->getType(),
];
}
return $list;
}
}

View File

@@ -0,0 +1,438 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Reminder;
use Espo\Core\Field\DateTime;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Id\RecordIdGenerator;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Preferences;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Reminder;
use Espo\Modules\Crm\Entities\Task;
use Espo\ORM\Entity;
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;
/**
* @internal This class should not be removed as it's used by custom entities.
*
* @implements SaverInterface<CoreEntity>
*/
class Saver implements SaverInterface
{
protected string $dateAttribute = 'dateStart';
public function __construct(
private EntityManager $entityManager,
private RecordIdGenerator $idGenerator,
private User $user,
private Metadata $metadata
) {}
public function process(Entity $entity, Params $params): void
{
$entityType = $entity->getEntityType();
if (!$this->hasRemindersField($entityType)) {
return;
}
$dateAttribute = $this->getDateAttribute($entityType);
if ($this->toRemove($entity)) {
$this->deleteAll($entity);
return;
}
if (!$this->toProcess($entity, $dateAttribute)) {
return;
}
$typeList = $this->getTypeList();
$onlyRemindersFieldChanged = $this->onlyRemindersFieldChanged($entity, $dateAttribute);
if (!$entity->isNew() && !$onlyRemindersFieldChanged) {
$this->deleteAll($entity);
}
if (!$entity->isNew() && $onlyRemindersFieldChanged) {
$this->deleteAllForUser($entity);
}
$startString = $this->getStartString($entity, $dateAttribute);
if (!$startString) {
return;
}
$userIdList = $this->getUserIdList($entity);
if ($userIdList === []) {
return;
}
if ($onlyRemindersFieldChanged && in_array($this->user->getId(), $userIdList)) {
$userIdList = [$this->user->getId()];
}
$start = DateTime::fromString($startString);
foreach ($userIdList as $userId) {
$usePreferences = $userId !== $this->user->getId() ||
!$entity->has('reminders') && $entity->isNew();
$reminderList = $usePreferences ?
$this->getPreferencesReminderList($typeList, $userId, $entityType) :
$this->getReminderList($entity, $typeList);
foreach ($reminderList as $item) {
$this->createReminder($entity, $userId, $start, $item);
}
}
}
/**
* @return object{seconds: int, type: string}[]
*/
private function getEntityReminderDataList(CoreEntity $entity): array
{
$dataList = [];
/** @var iterable<Reminder> $collection */
$collection = $this->entityManager
->getRDBRepository(Reminder::ENTITY_TYPE)
->select(['seconds', 'type'])
->where([
'entityType' => $entity->getEntityType(),
'entityId' => $entity->getId(),
'userId' => $this->user->getId(),
])
->distinct()
->order('seconds')
->find();
foreach ($collection as $reminder) {
$dataList[] = (object) [
'seconds' => $reminder->getSeconds(),
'type' => $reminder->getType(),
];
}
return $dataList;
}
private function getDateAttribute(string $entityType): string
{
return $this->entityManager
->getDefs()
->getEntity($entityType)
->getField('reminders')
->getParam('dateField') ??
$this->dateAttribute;
}
private function hasRemindersField(string $entityType): bool
{
return $this->entityManager
->getDefs()
->getEntity($entityType)
->hasField('reminders');
}
private function isNewOrChanged(CoreEntity $entity, string $dateAttribute): bool
{
return $entity->isNew() ||
$this->toReCreate($entity) ||
$entity->isAttributeChanged('assignedUserId') ||
(
$entity->hasLinkMultipleField(Field::ASSIGNED_USERS) &&
$entity->isAttributeChanged(Field::ASSIGNED_USERS . 'Ids')
) ||
($entity->hasLinkMultipleField('users') && $entity->isAttributeChanged('usersIds')) ||
$entity->isAttributeChanged($dateAttribute);
}
private function toProcess(CoreEntity $entity, string $dateAttribute): bool
{
return $this->isNewOrChanged($entity, $dateAttribute) || $entity->has('reminders');
}
private function onlyRemindersFieldChanged(CoreEntity $entity, string $dateAttribute): bool
{
if ($this->isNewOrChanged($entity, $dateAttribute)) {
return false;
}
return $entity->isAttributeChanged('reminders');
}
/**
* @return string[]
*/
private function getTypeList(): array
{
return $this->entityManager
->getDefs()
->getEntity(Reminder::ENTITY_TYPE)
->getField('type')
->getParam('options') ?? [];
}
private function deleteAll(CoreEntity $entity): void
{
$query = $this->entityManager
->getQueryBuilder()
->delete()
->from(Reminder::ENTITY_TYPE)
->where([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($query);
}
private function deleteAllForUser(CoreEntity $entity): void
{
$query = $this->entityManager
->getQueryBuilder()
->delete()
->from(Reminder::ENTITY_TYPE)
->where([
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'userId' => $this->user->getId(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($query);
}
/**
* @return string[]
*/
private function getUserIdList(CoreEntity $entity): array
{
if ($entity->hasLinkMultipleField('users')) {
return $entity->getLinkMultipleIdList('users');
}
if ($entity->hasLinkMultipleField(Field::ASSIGNED_USERS)) {
return $entity->getLinkMultipleIdList(Field::ASSIGNED_USERS);
}
$userIdList = [];
if ($entity->get('assignedUserId')) {
$userIdList[] = $entity->get('assignedUserId');
}
return $userIdList;
}
private function getStartString(CoreEntity $entity, string $dateAttribute): ?string
{
$dateValue = $entity->get($dateAttribute);
if (!$entity->has($dateAttribute)) {
$reloadedEntity = $this->entityManager->getEntityById($entity->getEntityType(), $entity->getId());
if ($reloadedEntity) {
$dateValue = $reloadedEntity->get($dateAttribute);
}
}
return $dateValue;
}
/**
* @param string[] $typeList
* @return object{seconds: int, type: string}[]
*/
private function getReminderList(CoreEntity $entity, array $typeList): array
{
if ($entity->has('reminders')) {
/** @var ?stdClass[] $list */
$list = $entity->get('reminders');
if ($list === null) {
return [];
}
return $this->sanitizeList($list, $typeList);
}
return $this->getEntityReminderDataList($entity);
}
/**
* @param string[] $typeList
* @return object{seconds: int, type: string}[]
*/
private function getPreferencesReminderList(array $typeList, string $userId, string $entityType): array
{
$preferences = $this->entityManager->getRepositoryByClass(Preferences::class)->getById($userId);
if (!$preferences) {
return [];
}
$param = 'defaultReminders';
// @todo Refactor.
if ($entityType === Task::ENTITY_TYPE) {
$param = 'defaultRemindersTask';
}
/** @var stdClass[] $list */
$list = $preferences->get($param) ?? [];
return $this->sanitizeList($list, $typeList);
}
/**
* @param stdClass[] $list
* @param string[] $typeList
* @return object{seconds: int, type: string}[]
*/
private function sanitizeList(array $list, array $typeList): array
{
$result = [];
foreach ($list as $item) {
$seconds = ($item->seconds ?? null);
$type = ($item->type ?? null);
if (!is_int($seconds) || !in_array($type, $typeList)) {
continue;
}
$result[] = (object) [
'seconds' => $seconds,
'type' => $type,
];
}
return $result;
}
/**
* @param object{seconds: int, type: string} $item
*/
private function createReminder(
CoreEntity $entity,
string $userId,
DateTime $start,
object $item
): void {
$seconds = $item->seconds;
$type = $item->type;
$remindAt = $start->addSeconds(- $seconds);
if ($remindAt->isLessThan(DateTime::createNow())) {
return;
}
$query = $this->entityManager
->getQueryBuilder()
->insert()
->into(Reminder::ENTITY_TYPE)
->columns([
'id',
'entityId',
'entityType',
'type',
'userId',
'remindAt',
'startAt',
'seconds',
])
->values([
'id' => $this->idGenerator->generate(),
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
'type' => $type,
'userId' => $userId,
'remindAt' => $remindAt->toString(),
'startAt' => $start->toString(),
'seconds' => $seconds,
])
->build();
$this->entityManager->getQueryExecutor()->execute($query);
}
private function toRemove(CoreEntity $entity): bool
{
if (!$entity->isAttributeChanged('status')) {
return false;
}
$entityType = $entity->getEntityType();
$status = $entity->get('status');
$ignoreStatusList = [
...($this->metadata->get("scopes.$entityType.completedStatusList") ?? []),
...($this->metadata->get("scopes.$entityType.canceledStatusList") ?? []),
];
return in_array($status, $ignoreStatusList);
}
private function toReCreate(CoreEntity $entity): bool
{
if (!$entity->isAttributeChanged('status')) {
return false;
}
$entityType = $entity->getEntityType();
$statusFetched = $entity->getFetched('status');
$status = $entity->get('status');
$ignoreStatusList = [
...($this->metadata->get("scopes.$entityType.completedStatusList") ?? []),
...($this->metadata->get("scopes.$entityType.canceledStatusList") ?? []),
];
return in_array($statusFetched, $ignoreStatusList) && !in_array($status, $ignoreStatusList);
}
}

View File

@@ -0,0 +1,105 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing;
use Espo\Core\ORM\Entity;
use Espo\Core\FieldProcessing\Saver\Params;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
/**
* Processes saving special fields.
*/
class SaveProcessor
{
/** @var array<string, Saver<Entity>[]> */
private $saverListMapCache = [];
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata
) {}
/**
* @param array<string, mixed> $options
*/
public function process(Entity $entity, array $options): void
{
$params = Params::create()->withRawOptions($options);
foreach ($this->getSaverList($entity->getEntityType()) as $processor) {
$processor->process($entity, $params);
}
}
/**
* @return Saver<Entity>[]
*/
private function getSaverList(string $entityType): array
{
if (array_key_exists($entityType, $this->saverListMapCache)) {
return $this->saverListMapCache[$entityType];
}
$list = [];
foreach ($this->getSaverClassNameList($entityType) as $className) {
$list[] = $this->createSaver($className);
}
$this->saverListMapCache[$entityType] = $list;
return $list;
}
/**
* @return class-string<Saver<Entity>>[]
*/
private function getSaverClassNameList(string $entityType): array
{
$list = $this->metadata
->get(['app', 'fieldProcessing', 'saverClassNameList']) ?? [];
$additionalList = $this->metadata
->get(['recordDefs', $entityType, 'saverClassNameList']) ?? [];
/** @var class-string<Saver<Entity>>[] */
return array_merge($list, $additionalList);
}
/**
* @param class-string<Saver<Entity>> $className
* @return Saver<Entity>
*/
private function createSaver(string $className): Saver
{
return $this->injectableFactory->create($className);
}
}

View File

@@ -0,0 +1,46 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Saver\Params;
/**
* Processes saving special fields.
*
* @template TEntity of Entity
*/
interface Saver
{
/**
* @param TEntity $entity
*/
public function process(Entity $entity, Params $params): void;
}

View File

@@ -0,0 +1,83 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Saver;
/**
* Immutable.
*/
class Params
{
/**
* @var array<string, mixed>
*/
private $options = [];
public function __construct()
{
}
public function hasOption(string $option): bool
{
return array_key_exists($option, $this->options);
}
/**
* @return mixed
*/
public function getOption(string $option)
{
return $this->options[$option] ?? null;
}
/**
* @return array<string, mixed>
*/
public function getRawOptions(): array
{
return $this->options;
}
/**
* @param array<string, mixed> $options
*/
public function withRawOptions(array $options): self
{
$obj = clone $this;
$obj->options = $options;
return $obj;
}
public static function create(): self
{
return new self();
}
}

View File

@@ -0,0 +1,68 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing;
use Espo\Core\InjectableFactory;
use Espo\ORM\Defs;
use Espo\ORM\Entity;
use RuntimeException;
/**
* @since 9.1.0
* @internal Yet experimental.
*/
class SpecificFieldLoader
{
public function __construct(
private Defs $defs,
private InjectableFactory $injectableFactory,
) {}
public function process(Entity $entity, string $field): void
{
/** @var ?class-string<Loader<Entity>> $loaderClassName */
$loaderClassName = $this->defs
->getEntity($entity->getEntityType())
->tryGetField($field)
?->getParam('loaderClassName');
if (!$loaderClassName) {
return;
}
$loader = $this->injectableFactory->create($loaderClassName);
if (!$loader instanceof Loader) {
throw new RuntimeException("Bad field loader.");
}
$loader->process($entity, Loader\Params::create());
}
}

View File

@@ -0,0 +1,60 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Stars;
use Espo\Core\FieldProcessing\Loader;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\Name\Field;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\Tools\Stars\StarService;
/**
* @implements Loader<Entity>
*/
class StarLoader implements Loader
{
public function __construct(
private StarService $service,
private User $user
) {}
public function process(Entity $entity, Params $params): void
{
if (
!$entity->hasAttribute(Field::IS_STARRED) ||
!$this->service->isEnabled($entity->getEntityType())
) {
return;
}
$entity->set(Field::IS_STARRED, $this->service->isStarred($entity, $this->user));
}
}

View File

@@ -0,0 +1,95 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Stream;
use Espo\Core\Name\Field;
use Espo\ORM\Entity;
use Espo\Core\Acl;
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Tools\Stream\Service as StreamService;
/**
* @implements LoaderInterface<Entity>
*/
class FollowersLoader implements LoaderInterface
{
private const FOLLOWERS_LIMIT = 6;
public function __construct(
private StreamService $streamService,
private Metadata $metadata,
private User $user,
private Acl $acl,
private Config $config
) {}
public function process(Entity $entity, Params $params): void
{
$this->processIsFollowed($entity);
$this->processFollowers($entity);
}
public function processIsFollowed(Entity $entity): void
{
if (!$entity->hasAttribute(Field::IS_FOLLOWED)) {
return;
}
$isFollowed = $this->streamService->checkIsFollowed($entity);
$entity->set(Field::IS_FOLLOWED, $isFollowed);
}
public function processFollowers(Entity $entity): void
{
if ($this->user->isPortal()) {
return;
}
if (!$this->metadata->get(['scopes', $entity->getEntityType(), 'stream'])) {
return;
}
if (!$this->acl->checkEntityStream($entity)) {
return;
}
$limit = $this->config->get('recordFollowersLoadLimit') ?? self::FOLLOWERS_LIMIT;
$data = $this->streamService->getEntityFollowers($entity, 0, $limit);
$entity->set(Field::FOLLOWERS . 'Ids', $data['idList']);
$entity->set(Field::FOLLOWERS . 'Names', $data['nameMap']);
}
}

View File

@@ -0,0 +1,74 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\VersionNumber;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Entity;
class BeforeSaveProcessor
{
private const ATTRIBUTE_VERSION_NUMBER = Field::VERSION_NUMBER;
public function __construct(private Metadata $metadata)
{}
public function process(Entity $entity): void
{
$optimisticConcurrencyControl = $this->metadata
->get(['entityDefs', $entity->getEntityType(), 'optimisticConcurrencyControl']);
if (!$optimisticConcurrencyControl) {
return;
}
if ($entity->isNew()) {
$entity->set(self::ATTRIBUTE_VERSION_NUMBER, 1);
return;
}
$entity->clear(self::ATTRIBUTE_VERSION_NUMBER);
if (!$entity->hasFetched(self::ATTRIBUTE_VERSION_NUMBER)) {
return;
}
$versionNumber = $entity->getFetched(self::ATTRIBUTE_VERSION_NUMBER);
if ($versionNumber === null) {
$versionNumber = 0;
}
$versionNumber++;
$entity->set(self::ATTRIBUTE_VERSION_NUMBER, $versionNumber);
}
}

View File

@@ -0,0 +1,134 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\FieldProcessing\Wysiwyg;
use Espo\Core\ORM\Type\FieldType;
use Espo\Entities\Attachment;
use Espo\ORM\Entity;
use Espo\Core\FieldProcessing\Saver as SaverInterface;
use Espo\Core\FieldProcessing\Saver\Params;
use Espo\Core\ORM\EntityManager;
/**
* @implements SaverInterface<Entity>
*/
class Saver implements SaverInterface
{
/** @var array<string, string[]> */
private $fieldListMapCache = [];
public function __construct(private EntityManager $entityManager)
{}
public function process(Entity $entity, Params $params): void
{
foreach ($this->getFieldList($entity->getEntityType()) as $name) {
$this->processItem($entity, $name);
}
}
private function processItem(Entity $entity, string $name): void
{
if (!$entity->has($name)) {
return;
}
if (!$entity->isAttributeChanged($name)) {
return;
}
$contents = $entity->get($name);
if (!$contents) {
return;
}
$matches = [];
$matchResult = preg_match_all("/\?entryPoint=attachment&amp;id=([^&=\"']+)/", $contents, $matches);
if (!$matchResult) {
return;
}
if (empty($matches[1]) || !is_array($matches[1])) {
return;
}
foreach ($matches[1] as $id) {
$attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getById($id);
if (!$attachment) {
continue;
}
if ($attachment->getRelated()) {
continue;
}
$attachment->set([
'relatedId' => $entity->getId(),
'relatedType' => $entity->getEntityType(),
]);
$this->entityManager->saveEntity($attachment);
}
}
/**
* @return string[]
*/
private function getFieldList(string $entityType): array
{
if (array_key_exists($entityType, $this->fieldListMapCache)) {
return $this->fieldListMapCache[$entityType];
}
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entityType);
$list = [];
foreach ($entityDefs->getFieldNameList() as $name) {
$defs = $entityDefs->getField($name);
if ($defs->getType() !== FieldType::WYSIWYG) {
continue;
}
$list[] = $name;
}
$this->fieldListMapCache[$entityType] = $list;
return $list;
}
}