Initial commit
This commit is contained in:
87
application/Espo/Modules/Crm/Tools/Activities/Api/Get.php
Normal file
87
application/Espo/Modules/Crm/Tools/Activities/Api/Get.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?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\Modules\Crm\Tools\Activities\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Record\SearchParamsFetcher;
|
||||
use Espo\Modules\Crm\Tools\Activities\FetchParams as ActivitiesFetchParams;
|
||||
use Espo\Modules\Crm\Tools\Activities\Service as Service;
|
||||
|
||||
/**
|
||||
* Activities related to a record.
|
||||
*/
|
||||
class Get implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private Service $service,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
if (!$this->acl->check('Activities')) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$parentType = $request->getRouteParam('parentType');
|
||||
$id = $request->getRouteParam('id');
|
||||
$type = $request->getRouteParam('type');
|
||||
|
||||
if (
|
||||
!$parentType ||
|
||||
!$id ||
|
||||
!in_array($type, ['activities', 'history'])
|
||||
) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$searchParams = $this->searchParamsFetcher->fetch($request);
|
||||
|
||||
$offset = $searchParams->getOffset();
|
||||
$maxSize = $searchParams->getMaxSize();
|
||||
|
||||
$targetEntityType = $request->getQueryParam('entityType');
|
||||
|
||||
$fetchParams = new ActivitiesFetchParams($maxSize, $offset, $targetEntityType);
|
||||
|
||||
$result = $type === 'history' ?
|
||||
$this->service->getHistory($parentType, $id, $fetchParams) :
|
||||
$this->service->getActivities($parentType, $id, $fetchParams);
|
||||
|
||||
return ResponseComposer::json($result->toApiOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?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\Modules\Crm\Tools\Activities\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Modules\Crm\Tools\Activities\ComposeEmailService;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class GetComposeAddressList implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private EntityManager $entityManager,
|
||||
private ComposeEmailService $service
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$parentType = $request->getRouteParam('parentType');
|
||||
$id = $request->getRouteParam('id');
|
||||
|
||||
if (!$parentType || !$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$entity = $this->fetchEntity($parentType, $id);
|
||||
|
||||
$list = $this->service->getEmailAddressList($entity);
|
||||
|
||||
return ResponseComposer::json(
|
||||
array_map(fn ($item) => $item->getValueMap(), $list)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function fetchEntity(string $parentType, string $id): Entity
|
||||
{
|
||||
$entity = $this->entityManager->getEntityById($parentType, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityRead($entity)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
}
|
||||
@@ -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\Modules\Crm\Tools\Activities\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Record\SearchParamsFetcher;
|
||||
use Espo\Modules\Crm\Tools\Activities\Service as Service;
|
||||
|
||||
/**
|
||||
* Activities of specific entity type related to a record.
|
||||
*/
|
||||
class GetListTyped implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private Service $service
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$parentType = $request->getRouteParam('parentType');
|
||||
$id = $request->getRouteParam('id');
|
||||
$type = $request->getRouteParam('type');
|
||||
$targetType = $request->getRouteParam('targetType');
|
||||
|
||||
if (
|
||||
!$parentType ||
|
||||
!$id ||
|
||||
!$type ||
|
||||
!$targetType
|
||||
) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
if ($type === 'activities') {
|
||||
$isHistory = false;
|
||||
} else if ($type === 'history') {
|
||||
$isHistory = true;
|
||||
} else {
|
||||
throw new BadRequest("Bad type.");
|
||||
}
|
||||
|
||||
$searchParams = $this->searchParamsFetcher->fetch($request);
|
||||
|
||||
$result = $this->service->findActivitiesEntityType(
|
||||
$parentType,
|
||||
$id,
|
||||
$targetType,
|
||||
$isHistory,
|
||||
$searchParams
|
||||
);
|
||||
|
||||
return ResponseComposer::json($result->toApiOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?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\Modules\Crm\Tools\Activities\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Record\SearchParamsFetcher;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Tools\Activities\Upcoming\Params;
|
||||
use Espo\Modules\Crm\Tools\Activities\UpcomingService;
|
||||
|
||||
/**
|
||||
* Upcoming activities.
|
||||
*
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class GetUpcoming implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private User $user,
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private UpcomingService $service
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$userId = $request->getQueryParam('userId') ?? $this->user->getId();
|
||||
|
||||
$params = $this->fetchParams($request);
|
||||
|
||||
$result = $this->service->get($userId, $params);
|
||||
|
||||
return ResponseComposer::json($result->toApiOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function fetchParams(Request $request): Params
|
||||
{
|
||||
$entityTypeList = $this->fetchEntityTypeList($request);
|
||||
$futureDays = $request->hasQueryParam('futureDays') ? intval($request->getQueryParam('futureDays')) : null;
|
||||
$searchParams = $this->searchParamsFetcher->fetch($request);
|
||||
|
||||
return new Params(
|
||||
offset: $searchParams->getOffset(),
|
||||
maxSize: $searchParams->getMaxSize(),
|
||||
futureDays: $futureDays,
|
||||
entityTypeList: $entityTypeList,
|
||||
includeShared: $request->getQueryParam('includeShared') === 'true',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?string[]
|
||||
* @throws BadRequest
|
||||
*/
|
||||
private function fetchEntityTypeList(Request $request): ?array
|
||||
{
|
||||
$entityTypeList = $request->getQueryParams()['entityTypeList'] ?? null;
|
||||
|
||||
if (!is_array($entityTypeList) && $entityTypeList !== null) {
|
||||
throw new BadRequest("Bad entityTypeList.");
|
||||
}
|
||||
|
||||
foreach ($entityTypeList ?? [] as $it) {
|
||||
if (!is_string($it)) {
|
||||
throw new BadRequest("Bad item in entityTypeList.");
|
||||
}
|
||||
}
|
||||
|
||||
return $entityTypeList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?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\Modules\Crm\Tools\Activities;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Field\EmailAddress;
|
||||
use Espo\Core\Templates\Entities\Company;
|
||||
use Espo\Core\Templates\Entities\Person;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Account;
|
||||
use Espo\Modules\Crm\Entities\Contact;
|
||||
use Espo\Modules\Crm\Entities\Lead;
|
||||
use Espo\ORM\Defs\RelationDefs;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Type\RelationType;
|
||||
use Espo\Tools\Email\EmailAddressEntityPair;
|
||||
|
||||
class ComposeEmailService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Metadata $metadata,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return EmailAddressEntityPair[]
|
||||
*/
|
||||
public function getEmailAddressList(Entity $entity): array
|
||||
{
|
||||
$relations = $this->getRelations($entity);
|
||||
|
||||
foreach ($relations as $relation) {
|
||||
$address = $this->getPersonEmailAddress($entity, $relation->getName());
|
||||
|
||||
if ($address) {
|
||||
return [$address];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function getPersonEmailAddress(Entity $entity, string $link): ?EmailAddressEntityPair
|
||||
{
|
||||
$foreignEntity = $this->entityManager
|
||||
->getRDBRepository($entity->getEntityType())
|
||||
->getRelation($entity, $link)
|
||||
->findOne();
|
||||
|
||||
if (!$foreignEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityRead($foreignEntity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField($foreignEntity->getEntityType(), 'emailAddress')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ?string $address */
|
||||
$address = $foreignEntity->get('emailAddress');
|
||||
|
||||
if (!$address) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$emailAddress = EmailAddress::create($address);
|
||||
|
||||
return new EmailAddressEntityPair($emailAddress, $foreignEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return RelationDefs[]
|
||||
*/
|
||||
private function getRelations(Entity $entity): array
|
||||
{
|
||||
$relations = $this->entityManager
|
||||
->getDefs()
|
||||
->getEntity($entity->getEntityType())
|
||||
->getRelationList();
|
||||
|
||||
$targetRelations = [];
|
||||
|
||||
foreach ($relations as $relation) {
|
||||
if (
|
||||
$relation->getType() !== RelationType::BELONGS_TO &&
|
||||
$relation->getType() !== RelationType::HAS_ONE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$foreignEntityType = $relation->getForeignEntityType();
|
||||
|
||||
if (
|
||||
$foreignEntityType !== Account::ENTITY_TYPE &&
|
||||
$foreignEntityType !== Contact::ENTITY_TYPE &&
|
||||
$foreignEntityType !== Lead::ENTITY_TYPE &&
|
||||
$this->metadata->get("scopes.$foreignEntityType.type") !== Person::TEMPLATE_TYPE &&
|
||||
$this->metadata->get("scopes.$foreignEntityType.type") !== Company::TEMPLATE_TYPE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetRelations[] = $relation;
|
||||
}
|
||||
|
||||
return $this->sortRelations($targetRelations);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param RelationDefs[] $targetRelations
|
||||
* @return RelationDefs[]
|
||||
*/
|
||||
private function sortRelations(array $targetRelations): array
|
||||
{
|
||||
usort($targetRelations, function (RelationDefs $a, RelationDefs $b) {
|
||||
$entityTypeList = [
|
||||
Account::ENTITY_TYPE,
|
||||
Contact::ENTITY_TYPE,
|
||||
Lead::ENTITY_TYPE,
|
||||
];
|
||||
|
||||
$index1 = array_search($a->getForeignEntityType(), $entityTypeList);
|
||||
$index2 = array_search($b->getForeignEntityType(), $entityTypeList);
|
||||
|
||||
if ($index1 !== false && $index2 === false) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ($index1 === false && $index2 !== false) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($index1 !== false && $index2 !== false && $index1 !== $index2) {
|
||||
return $index1 - $index2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return $targetRelations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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\Modules\Crm\Tools\Activities;
|
||||
|
||||
class FetchParams
|
||||
{
|
||||
private ?int $maxSize;
|
||||
private ?int $offset;
|
||||
private ?string $entityType;
|
||||
|
||||
public function __construct(
|
||||
?int $maxSize,
|
||||
?int $offset,
|
||||
?string $entityType
|
||||
) {
|
||||
$this->maxSize = $maxSize;
|
||||
$this->offset = $offset;
|
||||
$this->entityType = $entityType;
|
||||
}
|
||||
|
||||
public function getMaxSize(): ?int
|
||||
{
|
||||
return $this->maxSize;
|
||||
}
|
||||
|
||||
public function getOffset(): ?int
|
||||
{
|
||||
return $this->offset;
|
||||
}
|
||||
|
||||
public function getEntityType(): ?string
|
||||
{
|
||||
return $this->entityType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?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\Modules\Crm\Tools\Activities;
|
||||
|
||||
use DateInterval;
|
||||
use DateTime;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\DateTime as DateTimeUtil;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Entities\Meeting;
|
||||
use Espo\Modules\Crm\Entities\Reminder;
|
||||
use Espo\Modules\Crm\Entities\Task;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Espo\Tools\PopupNotification\Item;
|
||||
use Espo\Tools\PopupNotification\Provider;
|
||||
use Exception;
|
||||
|
||||
class PopupNotificationsProvider implements Provider
|
||||
{
|
||||
private const REMINDER_PAST_HOURS = 24;
|
||||
|
||||
private Config $config;
|
||||
private EntityManager $entityManager;
|
||||
|
||||
public function __construct(
|
||||
Config $config,
|
||||
EntityManager $entityManager
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Item[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function get(User $user): array
|
||||
{
|
||||
$userId = $user->getId();
|
||||
|
||||
$dt = new DateTime();
|
||||
|
||||
$pastHours = $this->config->get('reminderPastHours', self::REMINDER_PAST_HOURS);
|
||||
|
||||
$now = $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
|
||||
|
||||
$nowShifted = $dt
|
||||
->sub(new DateInterval('PT' . $pastHours . 'H'))
|
||||
->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
|
||||
|
||||
/** @var iterable<Reminder> $reminderCollection */
|
||||
$reminderCollection = $this->entityManager
|
||||
->getRDBRepositoryByClass(Reminder::class)
|
||||
->select([
|
||||
'id',
|
||||
'entityType',
|
||||
'entityId',
|
||||
])
|
||||
->where([
|
||||
'type' => Reminder::TYPE_POPUP,
|
||||
'userId' => $userId,
|
||||
'remindAt<=' => $now,
|
||||
'startAt>' => $nowShifted,
|
||||
])
|
||||
->find();
|
||||
|
||||
$resultList = [];
|
||||
|
||||
foreach ($reminderCollection as $reminder) {
|
||||
$reminderId = $reminder->getId();
|
||||
$entityType = $reminder->getTargetEntityType();
|
||||
$entityId = $reminder->getTargetEntityId();
|
||||
|
||||
if (!$entityId || !$entityType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById($entityType, $entityId);
|
||||
|
||||
if (!$entity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = null;
|
||||
|
||||
if (
|
||||
$entity instanceof CoreEntity &&
|
||||
$entity->hasLinkMultipleField('users') &&
|
||||
$entity->hasAttribute('usersColumns')
|
||||
) {
|
||||
$status = $entity->getLinkMultipleColumn('users', 'status', $userId);
|
||||
|
||||
if ($status === Meeting::ATTENDEE_STATUS_DECLINED) {
|
||||
$this->removeReminder($reminderId);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$dateField = $entityType === Task::ENTITY_TYPE ?
|
||||
'dateEnd' :
|
||||
'dateStart';
|
||||
|
||||
$data = (object) [
|
||||
'id' => $entity->getId(),
|
||||
'entityType' => $entityType,
|
||||
'name' => $entity->get(Field::NAME),
|
||||
'dateField' => $dateField,
|
||||
'attributes' => (object) [
|
||||
$dateField => $entity->get($dateField),
|
||||
$dateField . 'Date' => $entity->get($dateField . 'Date'),
|
||||
],
|
||||
];
|
||||
|
||||
$resultList[] = new Item($reminderId, $data);
|
||||
}
|
||||
|
||||
return $resultList;
|
||||
}
|
||||
|
||||
private function removeReminder(string $id): void
|
||||
{
|
||||
$deleteQuery = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->delete()
|
||||
->from(Reminder::ENTITY_TYPE)
|
||||
->where([Attribute::ID => $id])
|
||||
->build();
|
||||
|
||||
$this->entityManager->getQueryExecutor()->execute($deleteQuery);
|
||||
}
|
||||
}
|
||||
1159
application/Espo/Modules/Crm/Tools/Activities/Service.php
Normal file
1159
application/Espo/Modules/Crm/Tools/Activities/Service.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
<?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\Modules\Crm\Tools\Activities\Upcoming;
|
||||
|
||||
class Params
|
||||
{
|
||||
/**
|
||||
* @param ?string[] $entityTypeList
|
||||
*/
|
||||
public function __construct(
|
||||
readonly public ?int $offset = null,
|
||||
readonly public ?int $maxSize = null,
|
||||
readonly public ?int $futureDays = null,
|
||||
readonly public ?array $entityTypeList = null,
|
||||
readonly public bool $includeShared = false,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
<?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\Modules\Crm\Tools\Activities;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Select\Bool\Filters\OnlyMy;
|
||||
use Espo\Core\Select\Bool\Filters\Shared;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\DateTime as DateTimeUtil;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\Preferences;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Classes\Select\Meeting\PrimaryFilters\Planned;
|
||||
use Espo\Modules\Crm\Classes\Select\Task\PrimaryFilters\Actual;
|
||||
use Espo\Modules\Crm\Entities\Task;
|
||||
use Espo\Modules\Crm\Tools\Activities\Upcoming\Params;
|
||||
use Espo\ORM\EntityCollection;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\Query\Select;
|
||||
use Espo\Core\Acl\Table;
|
||||
use Espo\Core\Record\Collection as RecordCollection;
|
||||
use Espo\Core\Select\Where\Item as WhereItem;
|
||||
use Espo\Core\Select\Where\ConverterFactory as WhereConverterFactory;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
|
||||
use Exception;
|
||||
use PDO;
|
||||
use DateTime;
|
||||
use RuntimeException;
|
||||
|
||||
class UpcomingService
|
||||
{
|
||||
private const UPCOMING_ACTIVITIES_FUTURE_DAYS = 1;
|
||||
private const UPCOMING_ACTIVITIES_TASK_FUTURE_DAYS = 7;
|
||||
|
||||
public function __construct(
|
||||
private WhereConverterFactory $whereConverterFactory,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private Config $config,
|
||||
private Metadata $metadata,
|
||||
private Acl $acl,
|
||||
private EntityManager $entityManager,
|
||||
private ServiceContainer $serviceContainer,
|
||||
private Config\ApplicationConfig $applicationConfig,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get upcoming activities.
|
||||
*
|
||||
* @return RecordCollection<Entity>
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function get(string $userId, Params $params): RecordCollection
|
||||
{
|
||||
/** @var ?User $user */
|
||||
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
|
||||
|
||||
if (!$user) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
$this->accessCheck($user);
|
||||
|
||||
$entityTypeList = $params->entityTypeList ?? $this->config->get('activitiesEntityList', []);
|
||||
|
||||
$futureDays = $params->futureDays ??
|
||||
$this->config->get('activitiesUpcomingFutureDays', self::UPCOMING_ACTIVITIES_FUTURE_DAYS);
|
||||
|
||||
$queryList = [];
|
||||
|
||||
foreach ($entityTypeList as $entityType) {
|
||||
if (
|
||||
!$this->metadata->get(['scopes', $entityType, 'activity']) &&
|
||||
$entityType !== Task::ENTITY_TYPE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!$this->acl->checkScope($entityType, Table::ACTION_READ) ||
|
||||
!$this->metadata->get(['entityDefs', $entityType, 'fields', 'dateStart']) ||
|
||||
!$this->metadata->get(['entityDefs', $entityType, 'fields', 'dateEnd'])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$queryList[] = $this->getEntityTypeQuery($entityType, $user, $futureDays, $params->includeShared);
|
||||
}
|
||||
|
||||
if ($queryList === []) {
|
||||
return RecordCollection::create(new EntityCollection(), 0);
|
||||
}
|
||||
|
||||
$builder = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->union();
|
||||
|
||||
foreach ($queryList as $query) {
|
||||
$builder->query($query);
|
||||
}
|
||||
|
||||
$unionCountQuery = $builder->build();
|
||||
|
||||
$countQuery = $this->entityManager->getQueryBuilder()
|
||||
->select()
|
||||
->fromQuery($unionCountQuery, 'c')
|
||||
->select('COUNT:(c.id)', 'count')
|
||||
->build();
|
||||
|
||||
$countSth = $this->entityManager->getQueryExecutor()->execute($countQuery);
|
||||
|
||||
$row = $countSth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$totalCount = $row['count'];
|
||||
|
||||
$offset = $params->offset ?? 0;
|
||||
$maxSize = $params->maxSize ?? 0;
|
||||
|
||||
$unionQuery = $builder
|
||||
->order('dateEndIsNull')
|
||||
->order('order')
|
||||
->order('dateStart')
|
||||
->order('dateEnd')
|
||||
->order('name')
|
||||
->limit($offset, $maxSize)
|
||||
->build();
|
||||
|
||||
$sth = $this->entityManager->getQueryExecutor()->execute($unionQuery);
|
||||
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
$collection = new EntityCollection();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $itemEntityType */
|
||||
$itemEntityType = $row['entityType'];
|
||||
/** @var string $itemId */
|
||||
$itemId = $row['id'];
|
||||
|
||||
$entity = $this->entityManager->getEntityById($itemEntityType, $itemId);
|
||||
|
||||
if (!$entity) {
|
||||
// @todo Revise.
|
||||
$entity = $this->entityManager->getNewEntity($itemEntityType);
|
||||
|
||||
$entity->set('id', $itemId);
|
||||
}
|
||||
|
||||
if (
|
||||
$entity instanceof CoreEntity &&
|
||||
$entity->hasLinkParentField(Field::PARENT)
|
||||
) {
|
||||
$entity->loadParentNameField(Field::PARENT);
|
||||
}
|
||||
|
||||
$this->serviceContainer->get($itemEntityType)->prepareEntityForOutput($entity);
|
||||
|
||||
$collection->append($entity);
|
||||
}
|
||||
|
||||
/** @var RecordCollection<Entity> */
|
||||
return RecordCollection::create($collection, $totalCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
*/
|
||||
private function getEntityTypeQuery(string $entityType, User $user, int $futureDays, bool $includeShared): Select
|
||||
{
|
||||
try {
|
||||
$beforeString = (new DateTime())->modify('+' . $futureDays . ' days')
|
||||
->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
|
||||
} catch (Exception $e) {
|
||||
throw new RuntimeException($e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$builder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from($entityType)
|
||||
->forUser($user)
|
||||
->withBoolFilter(OnlyMy::NAME)
|
||||
->withStrictAccessControl();
|
||||
|
||||
if ($includeShared && $this->metadata->get("scopes.$entityType.collaborators")) {
|
||||
$builder->withBoolFilter(Shared::NAME);
|
||||
}
|
||||
|
||||
$orderField = 'dateStart';
|
||||
$primaryFilter = Planned::NAME;
|
||||
|
||||
if ($entityType === Task::ENTITY_TYPE) {
|
||||
$orderField = 'dateEnd';
|
||||
$primaryFilter = Actual::NAME;
|
||||
}
|
||||
|
||||
$builder->withPrimaryFilter($primaryFilter);
|
||||
|
||||
$queryBuilder = $builder->buildQueryBuilder();
|
||||
|
||||
$this->apply($entityType, $user, $queryBuilder, $beforeString);
|
||||
|
||||
$queryBuilder->select([
|
||||
'id',
|
||||
'name',
|
||||
'dateStart',
|
||||
'dateEnd',
|
||||
['"' . $entityType . '"', 'entityType'],
|
||||
['IS_NULL:(dateEnd)', 'dateEndIsNull'],
|
||||
[$orderField, 'order'],
|
||||
]);
|
||||
|
||||
return $queryBuilder->build();
|
||||
}
|
||||
|
||||
private function getUserTimeZone(User $user): string
|
||||
{
|
||||
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $user->getId());
|
||||
|
||||
if ($preferences) {
|
||||
$timeZone = $preferences->get('timeZone');
|
||||
|
||||
if ($timeZone) {
|
||||
return $timeZone;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->applicationConfig->getTimeZone();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function accessCheck(Entity $entity): void
|
||||
{
|
||||
if ($entity instanceof User) {
|
||||
if (!$this->acl->checkUserPermission($entity, Acl\Permission::USER_CALENDAR)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->acl->check($entity, Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
*/
|
||||
private function applyTask(
|
||||
User $user,
|
||||
SelectBuilder $queryBuilder,
|
||||
string $beforeString
|
||||
): void {
|
||||
|
||||
$converter = $this->whereConverterFactory->create(Task::ENTITY_TYPE, $user);
|
||||
$timeZone = $this->getUserTimeZone($user);
|
||||
|
||||
$upcomingTaskFutureDays = $this->config->get(
|
||||
'activitiesUpcomingTaskFutureDays',
|
||||
self::UPCOMING_ACTIVITIES_TASK_FUTURE_DAYS
|
||||
);
|
||||
|
||||
$taskBeforeString = (new DateTime())
|
||||
->modify('+' . $upcomingTaskFutureDays . ' days')
|
||||
->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
|
||||
|
||||
$queryBuilder->where([
|
||||
'OR' => [
|
||||
[
|
||||
'dateStart' => null,
|
||||
'OR' => [
|
||||
'dateEnd' => null,
|
||||
$converter->convert(
|
||||
$queryBuilder,
|
||||
WhereItem::fromRaw([
|
||||
'type' => 'before',
|
||||
'attribute' => 'dateEnd',
|
||||
'value' => $taskBeforeString,
|
||||
])
|
||||
)->getRaw()
|
||||
]
|
||||
],
|
||||
[
|
||||
'dateStart!=' => null,
|
||||
'OR' => [
|
||||
$converter->convert(
|
||||
$queryBuilder,
|
||||
WhereItem::fromRaw([
|
||||
'type' => 'past',
|
||||
'attribute' => 'dateStart',
|
||||
'dateTime' => true,
|
||||
'timeZone' => $timeZone,
|
||||
])
|
||||
)->getRaw(),
|
||||
$converter->convert(
|
||||
$queryBuilder,
|
||||
WhereItem::fromRaw([
|
||||
'type' => 'today',
|
||||
'attribute' => 'dateStart',
|
||||
'dateTime' => true,
|
||||
'timeZone' => $timeZone,
|
||||
])
|
||||
)->getRaw(),
|
||||
$converter->convert(
|
||||
$queryBuilder,
|
||||
WhereItem::fromRaw([
|
||||
'type' => 'before',
|
||||
'attribute' => 'dateStart',
|
||||
'value' => $beforeString,
|
||||
])
|
||||
)->getRaw(),
|
||||
]
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
*/
|
||||
private function apply(
|
||||
string $entityType,
|
||||
User $user,
|
||||
SelectBuilder $queryBuilder,
|
||||
string $beforeString
|
||||
): void {
|
||||
|
||||
if ($entityType === Task::ENTITY_TYPE) {
|
||||
$this->applyTask($user, $queryBuilder, $beforeString);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$converter = $this->whereConverterFactory->create($entityType, $user);
|
||||
$timeZone = $this->getUserTimeZone($user);
|
||||
|
||||
$queryBuilder->where([
|
||||
'OR' => [
|
||||
$converter->convert(
|
||||
$queryBuilder,
|
||||
WhereItem::fromRaw([
|
||||
'type' => 'today',
|
||||
'attribute' => 'dateStart',
|
||||
'dateTime' => true,
|
||||
'timeZone' => $timeZone,
|
||||
])
|
||||
)->getRaw(),
|
||||
[
|
||||
$converter->convert(
|
||||
$queryBuilder,
|
||||
WhereItem::fromRaw([
|
||||
'type' => 'future',
|
||||
'attribute' => 'dateEnd',
|
||||
'dateTime' => true,
|
||||
'timeZone' => $timeZone,
|
||||
])
|
||||
)->getRaw(),
|
||||
$converter->convert(
|
||||
$queryBuilder,
|
||||
WhereItem::fromRaw([
|
||||
'type' => 'before',
|
||||
'attribute' => 'dateStart',
|
||||
'value' => $beforeString,
|
||||
])
|
||||
)->getRaw(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?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\Modules\Crm\Tools\Calendar\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Item as CalendarItem;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Service;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Busy-ranges.
|
||||
*/
|
||||
class GetBusyRanges implements Action
|
||||
{
|
||||
public function __construct(private Service $calendarService) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$from = $request->getQueryParam('from');
|
||||
$to = $request->getQueryParam('to');
|
||||
$userIdListString = $request->getQueryParam('userIdList');
|
||||
|
||||
if (!$from || !$to || !$userIdListString) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$userIdList = explode(',', $userIdListString);
|
||||
|
||||
$map = $this->calendarService->fetchBusyRangesForUsers(
|
||||
$userIdList,
|
||||
DateTime::fromString($from),
|
||||
DateTime::fromString($to),
|
||||
$request->getQueryParam('entityType'),
|
||||
$request->getQueryParam('entityId')
|
||||
);
|
||||
|
||||
$result = (object) [];
|
||||
|
||||
foreach ($map as $userId => $itemList) {
|
||||
$result->$userId = self::itemListToRaw($itemList);
|
||||
}
|
||||
|
||||
return ResponseComposer::json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param CalendarItem[] $itemList
|
||||
* @return stdClass[]
|
||||
*/
|
||||
private static function itemListToRaw(array $itemList): array
|
||||
{
|
||||
return array_map(fn (CalendarItem $item) => $item->getRaw(), $itemList);
|
||||
}
|
||||
}
|
||||
138
application/Espo/Modules/Crm/Tools/Calendar/Api/GetCalendar.php
Normal file
138
application/Espo/Modules/Crm/Tools/Calendar/Api/GetCalendar.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?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\Modules\Crm\Tools\Calendar\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Tools\Calendar\FetchParams;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Item as CalendarItem;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Service;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Calendar events.
|
||||
*/
|
||||
class GetCalendar implements Action
|
||||
{
|
||||
private const MAX_CALENDAR_RANGE = 123;
|
||||
|
||||
public function __construct(
|
||||
private Service $calendarService,
|
||||
private Acl $acl,
|
||||
private User $user
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
if (!$this->acl->check('Calendar')) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$from = $request->getQueryParam('from');
|
||||
$to = $request->getQueryParam('to');
|
||||
$isAgenda = $request->getQueryParam('agenda') === 'true';
|
||||
|
||||
if (empty($from) || empty($to)) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
if (strtotime($to) - strtotime($from) > self::MAX_CALENDAR_RANGE * 24 * 3600) {
|
||||
throw new Forbidden('Too long range.');
|
||||
}
|
||||
|
||||
$scopeList = null;
|
||||
|
||||
if ($request->getQueryParam('scopeList') !== null) {
|
||||
$scopeList = explode(',', $request->getQueryParam('scopeList'));
|
||||
}
|
||||
|
||||
$userId = $request->getQueryParam('userId');
|
||||
$userIdList = $request->getQueryParam('userIdList');
|
||||
$teamIdList = $request->getQueryParam('teamIdList');
|
||||
|
||||
$fetchParams = FetchParams
|
||||
::create(
|
||||
DateTime::fromString($from),
|
||||
DateTime::fromString($to)
|
||||
)
|
||||
->withScopeList($scopeList);
|
||||
|
||||
if ($teamIdList) {
|
||||
$teamIdList = explode(',', $teamIdList);
|
||||
|
||||
$raw = self::itemListToRaw(
|
||||
$this->calendarService->fetchForTeams($teamIdList, $fetchParams)
|
||||
);
|
||||
|
||||
return ResponseComposer::json($raw);
|
||||
}
|
||||
|
||||
if ($userIdList) {
|
||||
$userIdList = explode(',', $userIdList);
|
||||
|
||||
$raw = self::itemListToRaw(
|
||||
$this->calendarService->fetchForUsers($userIdList, $fetchParams)
|
||||
);
|
||||
|
||||
return ResponseComposer::json($raw);
|
||||
}
|
||||
|
||||
if (!$userId) {
|
||||
$userId = $this->user->getId();
|
||||
}
|
||||
|
||||
$fetchParams = $fetchParams
|
||||
->withIsAgenda($isAgenda)
|
||||
->withWorkingTimeRanges();
|
||||
|
||||
$raw = self::itemListToRaw(
|
||||
$this->calendarService->fetch($userId, $fetchParams)
|
||||
);
|
||||
|
||||
return ResponseComposer::json($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param CalendarItem[] $itemList
|
||||
* @return stdClass[]
|
||||
*/
|
||||
private static function itemListToRaw(array $itemList): array
|
||||
{
|
||||
return array_map(fn (CalendarItem $item) => $item->getRaw(), $itemList);
|
||||
}
|
||||
}
|
||||
116
application/Espo/Modules/Crm/Tools/Calendar/Api/GetTimeline.php
Normal file
116
application/Espo/Modules/Crm/Tools/Calendar/Api/GetTimeline.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?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\Modules\Crm\Tools\Calendar\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Modules\Crm\Tools\Calendar\FetchParams;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Item as CalendarItem;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Service;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Get timeline items.
|
||||
*/
|
||||
class GetTimeline implements Action
|
||||
{
|
||||
private const MAX_CALENDAR_RANGE = 123;
|
||||
|
||||
public function __construct(
|
||||
private Service $calendarService,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
if (!$this->acl->check('Calendar')) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$from = $request->getQueryParam('from');
|
||||
$to = $request->getQueryParam('to');
|
||||
|
||||
if (empty($from) || empty($to)) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
if (strtotime($to) - strtotime($from) > self::MAX_CALENDAR_RANGE * 24 * 3600) {
|
||||
throw new Forbidden('Too long range.');
|
||||
}
|
||||
|
||||
$scopeList = null;
|
||||
|
||||
if ($request->getQueryParam('scopeList') !== null) {
|
||||
$scopeList = explode(',', $request->getQueryParam('scopeList'));
|
||||
}
|
||||
|
||||
$userId = $request->getQueryParam('userId');
|
||||
$userIdList = $request->getQueryParam('userIdList');
|
||||
|
||||
$userIdList = $userIdList ? explode(',', $userIdList) : [];
|
||||
|
||||
if ($userId) {
|
||||
$userIdList[] = $userId;
|
||||
}
|
||||
|
||||
$fetchParams = FetchParams
|
||||
::create(
|
||||
DateTime::fromString($from . ':00'),
|
||||
DateTime::fromString($to . ':00')
|
||||
)
|
||||
->withScopeList($scopeList);
|
||||
|
||||
$map = $this->calendarService->fetchTimelineForUsers($userIdList, $fetchParams);
|
||||
|
||||
$result = (object) [];
|
||||
|
||||
foreach ($map as $userId => $itemList) {
|
||||
$result->$userId = self::itemListToRaw($itemList);
|
||||
}
|
||||
|
||||
return ResponseComposer::json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param CalendarItem[] $itemList
|
||||
* @return stdClass[]
|
||||
*/
|
||||
private static function itemListToRaw(array $itemList): array
|
||||
{
|
||||
return array_map(fn (CalendarItem $item) => $item->getRaw(), $itemList);
|
||||
}
|
||||
}
|
||||
152
application/Espo/Modules/Crm/Tools/Calendar/FetchParams.php
Normal file
152
application/Espo/Modules/Crm/Tools/Calendar/FetchParams.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?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\Modules\Crm\Tools\Calendar;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
|
||||
class FetchParams
|
||||
{
|
||||
private DateTime $from;
|
||||
private DateTime $to;
|
||||
private bool $isAgenda = false;
|
||||
private bool $skipAcl = false;
|
||||
/** @var ?string[] */
|
||||
private ?array $scopeList = null;
|
||||
private bool $workingTimeRanges = false;
|
||||
private bool $workingTimeRangesInverted = false;
|
||||
|
||||
public function __construct(DateTime $from, DateTime $to)
|
||||
{
|
||||
$this->from = $from;
|
||||
$this->to = $to;
|
||||
}
|
||||
|
||||
public static function create(DateTime $from, DateTime $to): self
|
||||
{
|
||||
return new self($from, $to);
|
||||
}
|
||||
|
||||
public function withFrom(DateTime $from): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->from = $from;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function withTo(DateTime $to): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->to = $to;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function withIsAgenda(bool $isAgenda = true): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->isAgenda = $isAgenda;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function withSkipAcl(bool $skipAcl = true): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->skipAcl = $skipAcl;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string[] $scopeList
|
||||
*/
|
||||
public function withScopeList(?array $scopeList): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->scopeList = $scopeList;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function withWorkingTimeRanges(bool $workingTimeRanges = true): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->workingTimeRanges = $workingTimeRanges;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function withWorkingTimeRangesInverted(bool $workingTimeRangesInverted = true): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->workingTimeRangesInverted = $workingTimeRangesInverted;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function getFrom(): DateTime
|
||||
{
|
||||
return $this->from;
|
||||
}
|
||||
|
||||
public function getTo(): DateTime
|
||||
{
|
||||
return $this->to;
|
||||
}
|
||||
|
||||
public function isAgenda(): bool
|
||||
{
|
||||
return $this->isAgenda;
|
||||
}
|
||||
|
||||
public function skipAcl(): bool
|
||||
{
|
||||
return $this->skipAcl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?string[]
|
||||
*/
|
||||
public function getScopeList(): ?array
|
||||
{
|
||||
return $this->scopeList;
|
||||
}
|
||||
|
||||
public function workingTimeRanges(): bool
|
||||
{
|
||||
return $this->workingTimeRanges;
|
||||
}
|
||||
|
||||
public function workingTimeRangesInverted(): bool
|
||||
{
|
||||
return $this->workingTimeRangesInverted;
|
||||
}
|
||||
}
|
||||
@@ -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\Modules\Crm\Tools\Calendar\FreeBusy;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Items\Event;
|
||||
|
||||
readonly class FetchParams
|
||||
{
|
||||
/**
|
||||
* @param bool $accessCheck To user apply access check.
|
||||
* @param Event[] $ignoreEventList Events not to be included in a result.
|
||||
*/
|
||||
public function __construct(
|
||||
public DateTime $from,
|
||||
public DateTime $to,
|
||||
public bool $accessCheck = false,
|
||||
public array $ignoreEventList = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?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\Modules\Crm\Tools\Calendar\FreeBusy;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Tools\Calendar\FetchParams as CalendarFetchParams;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Items\BusyRange;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Service as CalendarService;
|
||||
|
||||
/**
|
||||
* @since 9.0.0
|
||||
*/
|
||||
class Service
|
||||
{
|
||||
public function __construct(
|
||||
private CalendarService $service,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetch busy-ranges for user. Access is not checked by default.
|
||||
*
|
||||
* @return BusyRange[]
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function fetchRanges(User $user, FetchParams $params): array
|
||||
{
|
||||
$fetchParams = CalendarFetchParams::create($params->from, $params->to);
|
||||
|
||||
return $this->service->fetchBusyRanges($user, $params, $fetchParams);
|
||||
}
|
||||
}
|
||||
39
application/Espo/Modules/Crm/Tools/Calendar/Item.php
Normal file
39
application/Espo/Modules/Crm/Tools/Calendar/Item.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?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\Modules\Crm\Tools\Calendar;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
|
||||
use stdClass;
|
||||
|
||||
interface Item
|
||||
{
|
||||
public function getRaw(): stdClass;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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\Modules\Crm\Tools\Calendar\Items;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Item;
|
||||
|
||||
use stdClass;
|
||||
|
||||
class BusyRange implements Item
|
||||
{
|
||||
private DateTime $start;
|
||||
private DateTime $end;
|
||||
|
||||
public function __construct(DateTime $start, DateTime $end)
|
||||
{
|
||||
$this->start = $start;
|
||||
$this->end = $end;
|
||||
}
|
||||
|
||||
public function getStart(): DateTime
|
||||
{
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
public function getEnd(): DateTime
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
public function getRaw(): stdClass
|
||||
{
|
||||
return (object) [
|
||||
'dateStart' => $this->start->toString(),
|
||||
'dateEnd' => $this->end->toString(),
|
||||
'isBusyRange' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
168
application/Espo/Modules/Crm/Tools/Calendar/Items/Event.php
Normal file
168
application/Espo/Modules/Crm/Tools/Calendar/Items/Event.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?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\Modules\Crm\Tools\Calendar\Items;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Item;
|
||||
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
|
||||
class Event implements Item
|
||||
{
|
||||
private ?DateTime $start;
|
||||
private ?DateTime $end;
|
||||
private string $entityType;
|
||||
/** @var array<string, mixed> */
|
||||
private array $attributes;
|
||||
/** @var string[] */
|
||||
private array $userIdList = [];
|
||||
/** @var array<string, string> */
|
||||
private array $userNameMap = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function __construct(?DateTime $start, ?DateTime $end, string $entityType, array $attributes)
|
||||
{
|
||||
$this->start = $start;
|
||||
$this->end = $end;
|
||||
$this->entityType = $entityType;
|
||||
$this->attributes = $attributes;
|
||||
}
|
||||
|
||||
public function getRaw(): stdClass
|
||||
{
|
||||
$obj = (object) [
|
||||
'scope' => $this->entityType,
|
||||
'dateStart' => $this->start?->toString(),
|
||||
'dateEnd' => $this->end?->toString(),
|
||||
];
|
||||
|
||||
if ($this->userIdList !== []) {
|
||||
$obj->userIdList = $this->userIdList;
|
||||
$obj->userNameMap = (object) $this->userNameMap;
|
||||
}
|
||||
|
||||
foreach ($this->attributes as $key => $value) {
|
||||
$obj->$key = $obj->$key ?? $value;
|
||||
}
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function withAttribute(string $name, $value): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->attributes[$name] = $value;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function withId(string $id): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->attributes['id'] = $id;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function withUserIdAdded(string $userId): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->userIdList[] = $userId;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $userNameMap
|
||||
*/
|
||||
public function withUserNameMap(array $userNameMap): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->userNameMap = $userNameMap;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
$id = $this->attributes['id'] ?? null;
|
||||
|
||||
if (!$id) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function getStart(): ?DateTime
|
||||
{
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
public function getEnd(): ?DateTime
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
public function getEntityType(): string
|
||||
{
|
||||
return $this->entityType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getAttributes(): array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getUserIdList(): array
|
||||
{
|
||||
return $this->userIdList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function getAttribute(string $name)
|
||||
{
|
||||
return $this->attributes[$name] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?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\Modules\Crm\Tools\Calendar\Items;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Item;
|
||||
use stdClass;
|
||||
|
||||
class NonWorkingRange implements Item
|
||||
{
|
||||
private DateTime $start;
|
||||
private DateTime $end;
|
||||
|
||||
public function __construct(DateTime $start, DateTime $end)
|
||||
{
|
||||
$this->start = $start;
|
||||
$this->end = $end;
|
||||
}
|
||||
|
||||
public function getStart(): DateTime
|
||||
{
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
public function getEnd(): DateTime
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
public function getRaw(): stdClass
|
||||
{
|
||||
return (object) [
|
||||
'dateStart' => $this->start->toString(),
|
||||
'dateEnd' => $this->end->toString(),
|
||||
'isNonWorkingRange' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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\Modules\Crm\Tools\Calendar\Items;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Modules\Crm\Tools\Calendar\Item;
|
||||
|
||||
use stdClass;
|
||||
|
||||
class WorkingRange implements Item
|
||||
{
|
||||
private DateTime $start;
|
||||
private DateTime $end;
|
||||
|
||||
public function __construct(DateTime $start, DateTime $end)
|
||||
{
|
||||
$this->start = $start;
|
||||
$this->end = $end;
|
||||
}
|
||||
|
||||
public function getStart(): DateTime
|
||||
{
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
public function getEnd(): DateTime
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
public function getRaw(): stdClass
|
||||
{
|
||||
return (object) [
|
||||
'dateStart' => $this->start->toString(),
|
||||
'dateEnd' => $this->end->toString(),
|
||||
'isWorkingRange' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
1115
application/Espo/Modules/Crm/Tools/Calendar/Service.php
Normal file
1115
application/Espo/Modules/Crm/Tools/Calendar/Service.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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\Modules\Crm\Tools\Campaign\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Acl\Table;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Modules\Crm\Entities\Campaign;
|
||||
use Espo\Modules\Crm\Tools\Campaign\MailMergeService;
|
||||
|
||||
/**
|
||||
* Generates mail merge PDFs.
|
||||
*/
|
||||
class PostGenerateMailMerge implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private MailMergeService $service,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id');
|
||||
$link = $request->getParsedBody()->link ?? null;
|
||||
|
||||
if (!$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
if (!$link) {
|
||||
throw new BadRequest("No `link`.");
|
||||
}
|
||||
|
||||
if (!$this->acl->checkScope(Campaign::ENTITY_TYPE, Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$attachmentId = $this->service->generate($id, $link);
|
||||
|
||||
return ResponseComposer::json(['id' => $attachmentId]);
|
||||
}
|
||||
}
|
||||
333
application/Espo/Modules/Crm/Tools/Campaign/LogService.php
Normal file
333
application/Espo/Modules/Crm/Tools/Campaign/LogService.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?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\Modules\Crm\Tools\Campaign;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Entities\EmailTemplate;
|
||||
use Espo\Modules\Crm\Entities\CampaignLogRecord;
|
||||
use Espo\Modules\Crm\Entities\CampaignTrackingUrl;
|
||||
use Espo\Modules\Crm\Entities\EmailQueueItem as QueueItem;
|
||||
use Espo\Modules\Crm\Entities\Lead;
|
||||
use Espo\Modules\Crm\Entities\MassEmail;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
class LogService
|
||||
{
|
||||
private EntityManager $entityManager;
|
||||
private Config $config;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
Config $config
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function logLeadCreated(string $campaignId, Lead $target): void
|
||||
{
|
||||
$actionDate = DateTime::createNow();
|
||||
|
||||
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
|
||||
|
||||
$logRecord->set([
|
||||
'campaignId' => $campaignId,
|
||||
'actionDate' => $actionDate->toString(),
|
||||
'parentId' => $target->getId(),
|
||||
'parentType' => $target->getEntityType(),
|
||||
'action' => CampaignLogRecord::ACTION_LEAD_CREATED,
|
||||
]);
|
||||
|
||||
$this->entityManager->saveEntity($logRecord);
|
||||
}
|
||||
|
||||
public function logSent(string $campaignId, QueueItem $queueItem, ?Entity $emailOrEmailTemplate = null): void
|
||||
{
|
||||
$queueItemId = $queueItem->getId();
|
||||
$isTest = $queueItem->isTest();
|
||||
|
||||
$actionDate = DateTime::createNow();
|
||||
|
||||
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
|
||||
|
||||
$logRecord->set([
|
||||
'campaignId' => $campaignId,
|
||||
'actionDate' => $actionDate->toString(),
|
||||
'parentId' => $queueItem->getTargetId(),
|
||||
'parentType' => $queueItem->getTargetType(),
|
||||
'action' => CampaignLogRecord::ACTION_SENT,
|
||||
'stringData' => $queueItem->getEmailAddress(),
|
||||
'queueItemId' => $queueItemId,
|
||||
'isTest' => $isTest,
|
||||
]);
|
||||
|
||||
if ($emailOrEmailTemplate) {
|
||||
$logRecord->set([
|
||||
'objectId' => $emailOrEmailTemplate->getId(),
|
||||
'objectType' => $emailOrEmailTemplate->getEntityType()
|
||||
]);
|
||||
}
|
||||
|
||||
$this->entityManager->saveEntity($logRecord);
|
||||
}
|
||||
|
||||
public function logBounced(string $campaignId, QueueItem $queueItem, bool $isHard = false): void
|
||||
{
|
||||
$queueItemId = $queueItem->getId();
|
||||
$isTest = $queueItem->isTest();
|
||||
$emailAddress = $queueItem->getEmailAddress();
|
||||
|
||||
if (
|
||||
$this->entityManager
|
||||
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
|
||||
->where([
|
||||
'queueItemId' => $queueItemId,
|
||||
'action' => CampaignLogRecord::ACTION_BOUNCED,
|
||||
'isTest' => $isTest,
|
||||
])
|
||||
->findOne()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actionDate = DateTime::createNow();
|
||||
|
||||
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
|
||||
|
||||
$logRecord->set([
|
||||
'campaignId' => $campaignId,
|
||||
'actionDate' => $actionDate->toString(),
|
||||
'parentId' => $queueItem->getTargetId(),
|
||||
'parentType' => $queueItem->getTargetType(),
|
||||
'action' => CampaignLogRecord::ACTION_BOUNCED,
|
||||
'stringData' => $emailAddress,
|
||||
'queueItemId' => $queueItemId,
|
||||
'isTest' => $isTest,
|
||||
]);
|
||||
|
||||
$logRecord->set(
|
||||
'stringAdditionalData',
|
||||
$isHard ?
|
||||
CampaignLogRecord::BOUNCED_TYPE_HARD :
|
||||
CampaignLogRecord::BOUNCED_TYPE_SOFT
|
||||
);
|
||||
|
||||
$this->entityManager->saveEntity($logRecord);
|
||||
}
|
||||
|
||||
public function logOptedIn(
|
||||
string $campaignId,
|
||||
?QueueItem $queueItem,
|
||||
Entity $target,
|
||||
?string $emailAddress = null
|
||||
): void {
|
||||
|
||||
if (
|
||||
$queueItem &&
|
||||
$this->entityManager
|
||||
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
|
||||
->where([
|
||||
'queueItemId' => $queueItem->getId(),
|
||||
'action' => CampaignLogRecord::ACTION_OPTED_IN,
|
||||
'isTest' => $queueItem->isTest(),
|
||||
])
|
||||
->findOne()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actionDate = DateTime::createNow();
|
||||
$emailAddress = $emailAddress ?? $target->get('emailAddress');
|
||||
|
||||
if (!$emailAddress && $queueItem) {
|
||||
$emailAddress = $queueItem->getEmailAddress();
|
||||
}
|
||||
|
||||
$queueItemId = null;
|
||||
$isTest = false;
|
||||
|
||||
if ($queueItem) {
|
||||
$queueItemId = $queueItem->getId();
|
||||
$isTest = $queueItem->isTest();
|
||||
}
|
||||
|
||||
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
|
||||
|
||||
$logRecord->set([
|
||||
'campaignId' => $campaignId,
|
||||
'actionDate' => $actionDate->toString(),
|
||||
'parentId' => $target->getId(),
|
||||
'parentType' => $target->getEntityType(),
|
||||
'action' => CampaignLogRecord::ACTION_OPTED_IN,
|
||||
'stringData' => $emailAddress,
|
||||
'queueItemId' => $queueItemId,
|
||||
'isTest' => $isTest,
|
||||
]);
|
||||
|
||||
$this->entityManager->saveEntity($logRecord);
|
||||
}
|
||||
|
||||
public function logOptedOut(
|
||||
string $campaignId,
|
||||
?QueueItem $queueItem,
|
||||
Entity $target,
|
||||
?string $emailAddress = null
|
||||
): void {
|
||||
|
||||
if (
|
||||
$queueItem &&
|
||||
$this->entityManager
|
||||
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
|
||||
->where([
|
||||
'queueItemId' => $queueItem->getId(),
|
||||
'action' => CampaignLogRecord::ACTION_OPTED_OUT,
|
||||
'isTest' => $queueItem->isTest(),
|
||||
])
|
||||
->findOne()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actionDate = DateTime::createNow();
|
||||
|
||||
$queueItemId = null;
|
||||
$isTest = false;
|
||||
|
||||
if ($queueItem) {
|
||||
$queueItemId = $queueItem->getId();
|
||||
$isTest = $queueItem->isTest();
|
||||
}
|
||||
|
||||
if (!$emailAddress && $queueItem) {
|
||||
$emailAddress = $queueItem->getEmailAddress();
|
||||
}
|
||||
|
||||
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
|
||||
|
||||
$logRecord->set([
|
||||
'campaignId' => $campaignId,
|
||||
'actionDate' => $actionDate->toString(),
|
||||
'parentId' => $target->getId(),
|
||||
'parentType' => $target->getEntityType(),
|
||||
'action' => CampaignLogRecord::ACTION_OPTED_OUT,
|
||||
'stringData' => $emailAddress,
|
||||
'queueItemId' => $queueItemId,
|
||||
'isTest' => $isTest
|
||||
]);
|
||||
|
||||
$this->entityManager->saveEntity($logRecord);
|
||||
}
|
||||
|
||||
public function logOpened(string $campaignId, QueueItem $queueItem): void
|
||||
{
|
||||
$actionDate = DateTime::createNow();
|
||||
|
||||
if (
|
||||
$this->entityManager
|
||||
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
|
||||
->where([
|
||||
'queueItemId' => $queueItem->getId(),
|
||||
'action' => CampaignLogRecord::ACTION_OPENED,
|
||||
'isTest' => $queueItem->isTest(),
|
||||
])
|
||||
->findOne()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$massEmail = $queueItem->getMassEmail();
|
||||
|
||||
if (!$massEmail) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
|
||||
|
||||
$logRecord->set([
|
||||
'campaignId' => $campaignId,
|
||||
'actionDate' => $actionDate->toString(),
|
||||
'parentId' => $queueItem->getTargetId(),
|
||||
'parentType' => $queueItem->getTargetType(),
|
||||
'action' => CampaignLogRecord::ACTION_OPENED,
|
||||
'objectId' => $massEmail->getEmailTemplateId(),
|
||||
'objectType' => EmailTemplate::ENTITY_TYPE,
|
||||
'queueItemId' => $queueItem->getId(),
|
||||
'isTest' => $queueItem->isTest(),
|
||||
]);
|
||||
|
||||
$this->entityManager->saveEntity($logRecord);
|
||||
}
|
||||
|
||||
public function logClicked(
|
||||
string $campaignId,
|
||||
QueueItem $queueItem,
|
||||
CampaignTrackingUrl $trackingUrl
|
||||
): void {
|
||||
|
||||
$actionDate = DateTime::createNow();
|
||||
|
||||
if ($this->config->get('massEmailOpenTracking')) {
|
||||
$this->logOpened($campaignId, $queueItem);
|
||||
}
|
||||
|
||||
if (
|
||||
$this->entityManager
|
||||
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
|
||||
->where([
|
||||
'queueItemId' => $queueItem->getId(),
|
||||
'action' => CampaignLogRecord::ACTION_CLICKED,
|
||||
'objectId' => $trackingUrl->getId(),
|
||||
'objectType' => $trackingUrl->getEntityType(),
|
||||
'isTest' => $queueItem->isTest(),
|
||||
])
|
||||
->findOne()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
|
||||
|
||||
$logRecord->set([
|
||||
'campaignId' => $campaignId,
|
||||
'actionDate' => $actionDate->toString(),
|
||||
'parentId' => $queueItem->getTargetId(),
|
||||
'parentType' => $queueItem->getTargetType(),
|
||||
'action' => CampaignLogRecord::ACTION_CLICKED,
|
||||
'objectId' => $trackingUrl->getId(),
|
||||
'objectType' => $trackingUrl->getEntityType(),
|
||||
'queueItemId' => $queueItem->getId(),
|
||||
'isTest' => $queueItem->isTest(),
|
||||
]);
|
||||
|
||||
$this->entityManager->saveEntity($logRecord);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?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\Modules\Crm\Tools\Campaign;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Field\LinkParent;
|
||||
use Espo\Core\FileStorage\Manager as FileStorageManager;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Util;
|
||||
use Espo\Entities\Attachment;
|
||||
use Espo\Entities\Template;
|
||||
use Espo\Modules\Crm\Entities\Campaign;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityCollection;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\Pdf\Builder;
|
||||
use Espo\Tools\Pdf\Data\DataLoaderManager;
|
||||
use Espo\Tools\Pdf\IdDataMap;
|
||||
use Espo\Tools\Pdf\Params;
|
||||
use Espo\Tools\Pdf\TemplateWrapper;
|
||||
use Espo\Tools\Pdf\ZipContents;
|
||||
|
||||
class MailMergeGenerator
|
||||
{
|
||||
private const DEFAULT_ENGINE = 'Dompdf';
|
||||
private const ATTACHMENT_MAIL_MERGE_ROLE = 'Mail Merge';
|
||||
|
||||
private EntityManager $entityManager;
|
||||
private DataLoaderManager $dataLoaderManager;
|
||||
private ServiceContainer $serviceContainer;
|
||||
private Builder $builder;
|
||||
private Config $config;
|
||||
private FileStorageManager $fileStorageManager;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
DataLoaderManager $dataLoaderManager,
|
||||
ServiceContainer $serviceContainer,
|
||||
Builder $builder,
|
||||
Config $config,
|
||||
FileStorageManager $fileStorageManager
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->dataLoaderManager = $dataLoaderManager;
|
||||
$this->serviceContainer = $serviceContainer;
|
||||
$this->builder = $builder;
|
||||
$this->config = $config;
|
||||
$this->fileStorageManager = $fileStorageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a mail-merge PDF.
|
||||
*
|
||||
* @return string An attachment ID.
|
||||
* @param EntityCollection<Entity> $collection
|
||||
* @throws Error
|
||||
*/
|
||||
public function generate(
|
||||
EntityCollection $collection,
|
||||
Template $template,
|
||||
?string $campaignId = null,
|
||||
?string $name = null
|
||||
): string {
|
||||
|
||||
$entityType = $collection->getEntityType();
|
||||
|
||||
if (!$entityType) {
|
||||
throw new Error("No entity type.");
|
||||
}
|
||||
|
||||
$name = $name ?? $campaignId ?? $entityType;
|
||||
|
||||
$params = Params::create()->withAcl();
|
||||
|
||||
$idDataMap = IdDataMap::create();
|
||||
|
||||
$service = $this->serviceContainer->get($entityType);
|
||||
|
||||
foreach ($collection as $entity) {
|
||||
$service->loadAdditionalFields($entity);
|
||||
|
||||
$idDataMap->set(
|
||||
$entity->getId(),
|
||||
$this->dataLoaderManager->load($entity, $params)
|
||||
);
|
||||
|
||||
// For bc.
|
||||
if (method_exists($service, 'loadAdditionalFieldsForPdf')) {
|
||||
$service->loadAdditionalFieldsForPdf($entity);
|
||||
}
|
||||
}
|
||||
|
||||
$engine = $this->config->get('pdfEngine') ?? self::DEFAULT_ENGINE;
|
||||
|
||||
$templateWrapper = new TemplateWrapper($template);
|
||||
|
||||
$printer = $this->builder
|
||||
->setTemplate($templateWrapper)
|
||||
->setEngine($engine)
|
||||
->build();
|
||||
|
||||
$contents = $printer->printCollection($collection, $params, $idDataMap);
|
||||
|
||||
$type = $contents instanceof ZipContents ?
|
||||
'application/zip' :
|
||||
'application/pdf';
|
||||
|
||||
$filename = $contents instanceof ZipContents ?
|
||||
Util::sanitizeFileName($name) . '.zip' :
|
||||
Util::sanitizeFileName($name) . '.pdf';
|
||||
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = $this->entityManager->getNewEntity(Attachment::ENTITY_TYPE);
|
||||
|
||||
$relatedLink = $campaignId ?
|
||||
LinkParent::create(Campaign::ENTITY_TYPE, $campaignId) : null;
|
||||
|
||||
$attachment
|
||||
->setRelated($relatedLink)
|
||||
->setSize($contents->getStream()->getSize())
|
||||
->setRole(self::ATTACHMENT_MAIL_MERGE_ROLE)
|
||||
->setName($filename)
|
||||
->setType($type);
|
||||
|
||||
$this->entityManager->saveEntity($attachment);
|
||||
|
||||
$this->fileStorageManager->putStream($attachment, $contents->getStream());
|
||||
|
||||
return $attachment->getId();
|
||||
}
|
||||
}
|
||||
231
application/Espo/Modules/Crm/Tools/Campaign/MailMergeService.php
Normal file
231
application/Espo/Modules/Crm/Tools/Campaign/MailMergeService.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?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\Modules\Crm\Tools\Campaign;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Entities\Template;
|
||||
use Espo\Modules\Crm\Entities\Campaign as CampaignEntity;
|
||||
use Espo\Modules\Crm\Entities\TargetList;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\Defs\Params\RelationParam;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityCollection;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
class MailMergeService
|
||||
{
|
||||
/** @var array<string, string[]> */
|
||||
protected $entityTypeAddressFieldListMap = [
|
||||
'Account' => ['billingAddress', 'shippingAddress'],
|
||||
'Contact' => ['address'],
|
||||
'Lead' => ['address'],
|
||||
'User' => [],
|
||||
];
|
||||
|
||||
/** @var string[] */
|
||||
protected $targetLinkList = [
|
||||
'accounts',
|
||||
'contacts',
|
||||
'leads',
|
||||
'users',
|
||||
];
|
||||
|
||||
private EntityManager $entityManager;
|
||||
private Acl $acl;
|
||||
private Language $defaultLanguage;
|
||||
private MailMergeGenerator $generator;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
Acl $acl,
|
||||
Language $defaultLanguage,
|
||||
MailMergeGenerator $generator
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->acl = $acl;
|
||||
$this->defaultLanguage = $defaultLanguage;
|
||||
$this->generator = $generator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string An attachment ID.
|
||||
* @throws BadRequest
|
||||
* @throws Error
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function generate(string $campaignId, string $link, bool $checkAcl = true): string
|
||||
{
|
||||
/** @var CampaignEntity $campaign */
|
||||
$campaign = $this->entityManager->getEntityById(CampaignEntity::ENTITY_TYPE, $campaignId);
|
||||
|
||||
if ($checkAcl && !$this->acl->checkEntityRead($campaign)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
/** @var string $targetEntityType */
|
||||
$targetEntityType = $campaign->getRelationParam($link, RelationParam::ENTITY);
|
||||
|
||||
if ($checkAcl && !$this->acl->check($targetEntityType, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden("Could not mail merge campaign because access to target entity type is forbidden.");
|
||||
}
|
||||
|
||||
if (!in_array($link, $this->targetLinkList)) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
if ($campaign->getType() !== CampaignEntity::TYPE_MAIL) {
|
||||
throw new Error("Could not mail merge campaign not of Mail type.");
|
||||
}
|
||||
|
||||
$templateId = $campaign->get($link . 'TemplateId');
|
||||
|
||||
if (!$templateId) {
|
||||
throw new Error("Could not mail merge campaign w/o specified template.");
|
||||
}
|
||||
|
||||
/** @var ?Template $template */
|
||||
$template = $this->entityManager->getEntityById(Template::ENTITY_TYPE, $templateId);
|
||||
|
||||
if (!$template) {
|
||||
throw new Error("Template not found.");
|
||||
}
|
||||
|
||||
if ($template->getTargetEntityType() !== $targetEntityType) {
|
||||
throw new Error("Template is not of proper entity type.");
|
||||
}
|
||||
|
||||
$campaign->loadLinkMultipleField('targetLists');
|
||||
$campaign->loadLinkMultipleField('excludingTargetLists');
|
||||
|
||||
if (count($campaign->getLinkMultipleIdList('targetLists')) === 0) {
|
||||
throw new Error("Could not mail merge campaign w/o any specified target list.");
|
||||
}
|
||||
|
||||
$metTargetHash = [];
|
||||
$targetEntityList = [];
|
||||
|
||||
/** @var Collection<TargetList> $excludingTargetListList */
|
||||
$excludingTargetListList = $this->entityManager
|
||||
->getRDBRepository(CampaignEntity::ENTITY_TYPE)
|
||||
->getRelation($campaign, 'excludingTargetLists')
|
||||
->find();
|
||||
|
||||
foreach ($excludingTargetListList as $excludingTargetList) {
|
||||
$recordList = $this->entityManager
|
||||
->getRDBRepository(TargetList::ENTITY_TYPE)
|
||||
->getRelation($excludingTargetList, $link)
|
||||
->find();
|
||||
|
||||
foreach ($recordList as $excludingTarget) {
|
||||
$hashId = $excludingTarget->getEntityType() . '-' . $excludingTarget->getId();
|
||||
$metTargetHash[$hashId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$addressFieldList = $this->entityTypeAddressFieldListMap[$targetEntityType];
|
||||
|
||||
/** @var Collection<TargetList> $targetListCollection */
|
||||
$targetListCollection = $this->entityManager
|
||||
->getRDBRepository(CampaignEntity::ENTITY_TYPE)
|
||||
->getRelation($campaign, 'targetLists')
|
||||
->find();
|
||||
|
||||
foreach ($targetListCollection as $targetList) {
|
||||
if (!$campaign->get($link . 'TemplateId')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entityList = $this->entityManager
|
||||
->getRDBRepository(TargetList::ENTITY_TYPE)
|
||||
->getRelation($targetList, $link)
|
||||
->where([
|
||||
'@relation.optedOut' => false,
|
||||
])
|
||||
->find();
|
||||
|
||||
foreach ($entityList as $e) {
|
||||
$hashId = $e->getEntityType() . '-'. $e->getId();
|
||||
|
||||
if (!empty($metTargetHash[$hashId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$metTargetHash[$hashId] = true;
|
||||
|
||||
if ($campaign->get('mailMergeOnlyWithAddress')) {
|
||||
if (empty($addressFieldList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasAddress = false;
|
||||
|
||||
foreach ($addressFieldList as $addressField) {
|
||||
if (
|
||||
$e->get($addressField . 'Street') ||
|
||||
$e->get($addressField . 'PostalCode')
|
||||
) {
|
||||
$hasAddress = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasAddress) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$targetEntityList[] = $e;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($targetEntityList)) {
|
||||
throw new Error("No targets available for mail merge.");
|
||||
}
|
||||
|
||||
$filename = $campaign->getName() . ' - ' .
|
||||
$this->defaultLanguage->translateLabel($targetEntityType, 'scopeNamesPlural');
|
||||
|
||||
/** @var EntityCollection<Entity> $collection */
|
||||
$collection = $this->entityManager
|
||||
->getCollectionFactory()
|
||||
->create($targetEntityType, $targetEntityList);
|
||||
|
||||
return $this->generator->generate(
|
||||
$collection,
|
||||
$template,
|
||||
$campaign->getId(),
|
||||
$filename
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?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\Modules\Crm\Tools\Case\Distribution;
|
||||
|
||||
use Espo\Modules\Crm\Entities\CaseObj;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\Team;
|
||||
|
||||
class LeastBusy
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Metadata $metadata
|
||||
) {}
|
||||
|
||||
public function getUser(Team $team, ?string $targetUserPosition = null): ?User
|
||||
{
|
||||
$where = [
|
||||
'isActive' => true,
|
||||
];
|
||||
|
||||
if (!empty($targetUserPosition)) {
|
||||
$where['@relation.role'] = $targetUserPosition;
|
||||
}
|
||||
|
||||
$userList = $this->entityManager
|
||||
->getRDBRepository(Team::ENTITY_TYPE)
|
||||
->getRelation($team, 'users')
|
||||
->where($where)
|
||||
->order('id')
|
||||
->find();
|
||||
|
||||
if (count($userList) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$countHash = [];
|
||||
|
||||
$notActualStatusList =
|
||||
$this->metadata->get(['entityDefs', 'Case', 'fields', 'status', 'notActualOptions']) ?? [];
|
||||
|
||||
foreach ($userList as $user) {
|
||||
$count = $this->entityManager
|
||||
->getRDBRepository(CaseObj::ENTITY_TYPE)
|
||||
->where([
|
||||
'assignedUserId' => $user->getId(),
|
||||
'status!=' => $notActualStatusList,
|
||||
])
|
||||
->count();
|
||||
|
||||
$countHash[$user->getId()] = $count;
|
||||
}
|
||||
|
||||
$foundUserId = false;
|
||||
$min = false;
|
||||
|
||||
foreach ($countHash as $userId => $count) {
|
||||
if ($min === false) {
|
||||
$min = $count;
|
||||
$foundUserId = $userId;
|
||||
} else if ($count < $min) {
|
||||
$min = $count;
|
||||
$foundUserId = $userId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($foundUserId !== false) {
|
||||
/** @var ?User */
|
||||
return $this->entityManager->getEntityById(User::ENTITY_TYPE, $foundUserId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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\Modules\Crm\Tools\Case\Distribution;
|
||||
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\Team;
|
||||
use Espo\Modules\Crm\Entities\CaseObj;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
class RoundRobin
|
||||
{
|
||||
public function __construct(private EntityManager $entityManager)
|
||||
{}
|
||||
|
||||
public function getUser(Team $team, ?string $targetUserPosition = null): ?User
|
||||
{
|
||||
$where = [
|
||||
'isActive' => true,
|
||||
];
|
||||
|
||||
if (!empty($targetUserPosition)) {
|
||||
$where['@relation.role'] = $targetUserPosition;
|
||||
}
|
||||
|
||||
$userList = $this->entityManager
|
||||
->getRDBRepositoryByClass(Team::class)
|
||||
->getRelation($team, 'users')
|
||||
->where($where)
|
||||
->order('id')
|
||||
->find();
|
||||
|
||||
if (count($userList) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userIdList = [];
|
||||
|
||||
foreach ($userList as $user) {
|
||||
$userIdList[] = $user->getId();
|
||||
}
|
||||
|
||||
/** @var ?CaseObj $case */
|
||||
$case = $this->entityManager
|
||||
->getRDBRepository(CaseObj::ENTITY_TYPE)
|
||||
->where([
|
||||
'assignedUserId' => $userIdList,
|
||||
])
|
||||
->order('number', 'DESC')
|
||||
->findOne();
|
||||
|
||||
if (empty($case)) {
|
||||
$num = 0;
|
||||
} else {
|
||||
$num = array_search($case->getAssignedUser()?->getId(), $userIdList);
|
||||
|
||||
if ($num === false || $num == count($userIdList) - 1) {
|
||||
$num = 0;
|
||||
} else {
|
||||
$num++;
|
||||
}
|
||||
}
|
||||
|
||||
$id = $userIdList[$num];
|
||||
|
||||
/** @var User */
|
||||
return $this->entityManager->getEntityById(User::ENTITY_TYPE, $id);
|
||||
}
|
||||
}
|
||||
339
application/Espo/Modules/Crm/Tools/Case/Service.php
Normal file
339
application/Espo/Modules/Crm/Tools/Case/Service.php
Normal file
@@ -0,0 +1,339 @@
|
||||
<?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\Modules\Crm\Tools\Case;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Field\EmailAddress;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Templates\Entities\Company;
|
||||
use Espo\Core\Templates\Entities\Person;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Account;
|
||||
use Espo\Modules\Crm\Entities\Contact;
|
||||
use Espo\Modules\Crm\Entities\CaseObj;
|
||||
use Espo\Modules\Crm\Entities\Lead;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Type\RelationType;
|
||||
use Espo\Tools\Email\EmailAddressEntityPair;
|
||||
use RuntimeException;
|
||||
|
||||
class Service
|
||||
{
|
||||
public function __construct(
|
||||
private ServiceContainer $serviceContainer,
|
||||
private Acl $acl,
|
||||
private EntityManager $entityManager,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private Metadata $metadata
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @return EmailAddressEntityPair[]
|
||||
*/
|
||||
public function getEmailAddressList(string $id): array
|
||||
{
|
||||
/** @var CaseObj $entity */
|
||||
$entity = $this->serviceContainer
|
||||
->get(CaseObj::ENTITY_TYPE)
|
||||
->getEntity($id);
|
||||
|
||||
$list = [];
|
||||
|
||||
if (
|
||||
$this->acl->checkField(CaseObj::ENTITY_TYPE, 'contacts') &&
|
||||
$this->acl->checkScope(Contact::ENTITY_TYPE)
|
||||
) {
|
||||
foreach ($this->getContactEmailAddressList($entity) as $item) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
$list === [] &&
|
||||
$this->acl->checkField(CaseObj::ENTITY_TYPE, 'account') &&
|
||||
$this->acl->checkScope(Account::ENTITY_TYPE)
|
||||
) {
|
||||
$item = $this->getAccountEmailAddress($entity, $list);
|
||||
|
||||
if ($item) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
$list === [] &&
|
||||
$this->acl->checkField(CaseObj::ENTITY_TYPE, 'lead') &&
|
||||
$this->acl->checkScope(Lead::ENTITY_TYPE)
|
||||
) {
|
||||
$item = $this->getLeadEmailAddress($entity, $list);
|
||||
|
||||
if ($item) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if ($list === []) {
|
||||
$item = $this->findPersonEmailAddress($entity);
|
||||
|
||||
if ($item) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EmailAddressEntityPair[] $dataList
|
||||
*/
|
||||
private function getAccountEmailAddress(CaseObj $entity, array $dataList): ?EmailAddressEntityPair
|
||||
{
|
||||
$account = $entity->getAccount();
|
||||
|
||||
if (!$account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$emailAddress = $account->getEmailAddress();
|
||||
|
||||
if (!$emailAddress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntity($account)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Account::ENTITY_TYPE, 'emailAddress')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($dataList as $item) {
|
||||
if ($item->getEmailAddress()->getAddress() === $emailAddress) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new EmailAddressEntityPair(EmailAddress::create($emailAddress), $account);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EmailAddressEntityPair[] $dataList
|
||||
*/
|
||||
private function getLeadEmailAddress(CaseObj $entity, array $dataList): ?EmailAddressEntityPair
|
||||
{
|
||||
$lead = $entity->getLead();
|
||||
|
||||
if (!$lead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$emailAddress = $lead->getEmailAddress();
|
||||
|
||||
if (!$emailAddress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntity($lead)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Lead::ENTITY_TYPE, 'emailAddress')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($dataList as $item) {
|
||||
if ($item->getEmailAddress()->getAddress() === $emailAddress) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new EmailAddressEntityPair(EmailAddress::create($emailAddress), $lead);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EmailAddressEntityPair[]
|
||||
*/
|
||||
private function getContactEmailAddressList(CaseObj $entity): array
|
||||
{
|
||||
$contactsLinkMultiple = $entity->getContacts();
|
||||
|
||||
$contactIdList = $contactsLinkMultiple->getIdList();
|
||||
|
||||
if (!count($contactIdList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Contact::ENTITY_TYPE, 'emailAddress')) {
|
||||
return[];
|
||||
}
|
||||
|
||||
$dataList = [];
|
||||
|
||||
$emailAddressList = [];
|
||||
|
||||
try {
|
||||
$query = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Contact::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder()
|
||||
->select([
|
||||
'id',
|
||||
'emailAddress',
|
||||
'name',
|
||||
])
|
||||
->where([
|
||||
'id' => $contactIdList,
|
||||
])
|
||||
->build();
|
||||
} catch (BadRequest|Forbidden $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
/** @var Collection<Contact> $contactCollection */
|
||||
$contactCollection = $this->entityManager
|
||||
->getRDBRepositoryByClass(Contact::class)
|
||||
->clone($query)
|
||||
->find();
|
||||
|
||||
foreach ($contactCollection as $contact) {
|
||||
$emailAddress = $contact->getEmailAddress();
|
||||
|
||||
if (!$emailAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($emailAddress, $emailAddressList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$emailAddressList[] = $emailAddress;
|
||||
|
||||
$dataList[] = new EmailAddressEntityPair(EmailAddress::create($emailAddress), $contact);
|
||||
}
|
||||
|
||||
$primaryContact = $entity->getContact();
|
||||
|
||||
if (!$primaryContact) {
|
||||
return $dataList;
|
||||
}
|
||||
|
||||
usort(
|
||||
$dataList,
|
||||
function (
|
||||
EmailAddressEntityPair $o1,
|
||||
EmailAddressEntityPair $o2
|
||||
) use ($primaryContact) {
|
||||
if ($o1->getEntity()->getId() === $primaryContact->getId()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ($o2->getEntity()->getId() === $primaryContact->getId()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
|
||||
return $dataList;
|
||||
}
|
||||
|
||||
private function findPersonEmailAddress(CaseObj $entity): ?EmailAddressEntityPair
|
||||
{
|
||||
$relations = $this->entityManager
|
||||
->getDefs()
|
||||
->getEntity(CaseObj::ENTITY_TYPE)
|
||||
->getRelationList();
|
||||
|
||||
foreach ($relations as $relation) {
|
||||
if (
|
||||
$relation->getType() !== RelationType::BELONGS_TO &&
|
||||
$relation->getType() !== RelationType::HAS_ONE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$foreignEntityType = $relation->getForeignEntityType();
|
||||
|
||||
if (
|
||||
$this->metadata->get("scopes.$foreignEntityType.type") !== Person::TEMPLATE_TYPE &&
|
||||
$this->metadata->get("scopes.$foreignEntityType.type") !== Company::TEMPLATE_TYPE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$address = $this->getPersonEmailAddress($entity, $relation->getName());
|
||||
|
||||
if ($address) {
|
||||
return $address;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getPersonEmailAddress(CaseObj $entity, string $link): ?EmailAddressEntityPair
|
||||
{
|
||||
$foreignEntity = $this->entityManager
|
||||
->getRDBRepositoryByClass(CaseObj::class)
|
||||
->getRelation($entity, $link)
|
||||
->findOne();
|
||||
|
||||
if (!$foreignEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityRead($foreignEntity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField($foreignEntity->getEntityType(), 'emailAddress')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ?string $address */
|
||||
$address = $foreignEntity->get('emailAddress');
|
||||
|
||||
if (!$address) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$emailAddress = EmailAddress::create($address);
|
||||
|
||||
return new EmailAddressEntityPair($emailAddress, $foreignEntity);
|
||||
}
|
||||
}
|
||||
122
application/Espo/Modules/Crm/Tools/Document/Service.php
Normal file
122
application/Espo/Modules/Crm/Tools/Document/Service.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?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\Modules\Crm\Tools\Document;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Entities\Attachment;
|
||||
use Espo\Modules\Crm\Entities\Document;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Repositories\Attachment as AttachmentRepository;
|
||||
use Espo\Tools\Attachment\AccessChecker as AttachmentAccessChecker;
|
||||
use Espo\Tools\Attachment\FieldData;
|
||||
|
||||
class Service
|
||||
{
|
||||
private EntityManager $entityManager;
|
||||
private AttachmentAccessChecker $attachmentAccessChecker;
|
||||
private ServiceContainer $serviceContainer;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
AttachmentAccessChecker $attachmentAccessChecker,
|
||||
ServiceContainer $serviceContainer
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->attachmentAccessChecker = $attachmentAccessChecker;
|
||||
$this->serviceContainer = $serviceContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an attachment for re-using (e.g. in an email).
|
||||
*
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
*/
|
||||
public function copyAttachment(string $id, FieldData $fieldData): Attachment
|
||||
{
|
||||
/** @var ?Document $entity */
|
||||
$entity = $this->serviceContainer
|
||||
->getByClass(Document::class)
|
||||
->getEntity($id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
$this->attachmentAccessChecker->check($fieldData);
|
||||
|
||||
$attachmentId = $entity->getFileId();
|
||||
|
||||
if (!$attachmentId) {
|
||||
throw new Error("No file.");
|
||||
}
|
||||
|
||||
$attachment = $this->copyAttachmentById($attachmentId, $fieldData);
|
||||
|
||||
if (!$attachment) {
|
||||
throw new Error("No file.");
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
private function copyAttachmentById(string $attachmentId, FieldData $fieldData): ?Attachment
|
||||
{
|
||||
/** @var ?Attachment $attachment */
|
||||
$attachment = $this->entityManager
|
||||
->getRDBRepositoryByClass(Attachment::class)
|
||||
->getById($attachmentId);
|
||||
|
||||
if (!$attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$copied = $this->getAttachmentRepository()->getCopiedAttachment($attachment);
|
||||
|
||||
$copied->set('parentType', $fieldData->getParentType());
|
||||
$copied->set('relatedType', $fieldData->getRelatedType());
|
||||
$copied->setTargetField($fieldData->getField());
|
||||
$copied->setRole(Attachment::ROLE_ATTACHMENT);
|
||||
|
||||
$this->getAttachmentRepository()->save($copied);
|
||||
|
||||
return $copied;
|
||||
}
|
||||
|
||||
private function getAttachmentRepository(): AttachmentRepository
|
||||
{
|
||||
/** @var AttachmentRepository */
|
||||
return $this->entityManager->getRepositoryByClass(Attachment::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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\Modules\Crm\Tools\EmailAddress;
|
||||
|
||||
use Espo\Core\Utils\File\Manager as FileManager;
|
||||
use Espo\Core\Utils\Json;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @since 9.0.3
|
||||
*/
|
||||
class DefaultFreeDomainChecker implements FreeDomainChecker
|
||||
{
|
||||
private string $file = 'application/Espo/Modules/Crm/Resources/data/freeEmailProviderDomains.json';
|
||||
|
||||
public function __construct(
|
||||
private FileManager $fileManager
|
||||
) {}
|
||||
|
||||
public function check(string $domain): bool
|
||||
{
|
||||
$list = Json::decode($this->fileManager->getContents($this->file));
|
||||
|
||||
if (!is_array($list)) {
|
||||
throw new RuntimeException("Bad data in freeEmailProviderDomains file.");
|
||||
}
|
||||
|
||||
return in_array($domain, $list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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\Modules\Crm\Tools\EmailAddress;
|
||||
|
||||
/**
|
||||
* @since 9.0.3
|
||||
*/
|
||||
interface FreeDomainChecker
|
||||
{
|
||||
/**
|
||||
* Check whether the domain belongs to a free email provider.
|
||||
*/
|
||||
public function check(string $domain): bool;
|
||||
}
|
||||
341
application/Espo/Modules/Crm/Tools/KnowledgeBase/Service.php
Normal file
341
application/Espo/Modules/Crm/Tools/KnowledgeBase/Service.php
Normal file
@@ -0,0 +1,341 @@
|
||||
<?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\Modules\Crm\Tools\KnowledgeBase;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Select\Where\Item as WhereItem;
|
||||
use Espo\Entities\Attachment;
|
||||
use Espo\Modules\Crm\Entities\KnowledgeBaseArticle;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Repositories\Attachment as AttachmentRepository;
|
||||
use Espo\Tools\Attachment\AccessChecker as AttachmentAccessChecker;
|
||||
use Espo\Tools\Attachment\FieldData;
|
||||
|
||||
class Service
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private AttachmentAccessChecker $attachmentAccessChecker,
|
||||
private ServiceContainer $serviceContainer,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private Acl $acl,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Copy article attachments for re-using (e.g. in an email).
|
||||
*
|
||||
* @return Attachment[]
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function copyAttachments(string $id, FieldData $fieldData): array
|
||||
{
|
||||
/** @var ?KnowledgeBaseArticle $entity */
|
||||
$entity = $this->serviceContainer
|
||||
->get(KnowledgeBaseArticle::ENTITY_TYPE)
|
||||
->getEntity($id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
$this->attachmentAccessChecker->check($fieldData);
|
||||
|
||||
$list = [];
|
||||
|
||||
foreach ($entity->getAttachmentIdList() as $attachmentId) {
|
||||
$attachment = $this->copyAttachment($attachmentId, $fieldData);
|
||||
|
||||
if ($attachment) {
|
||||
$list[] = $attachment;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function copyAttachment(string $attachmentId, FieldData $fieldData): ?Attachment
|
||||
{
|
||||
/** @var ?Attachment $attachment */
|
||||
$attachment = $this->entityManager
|
||||
->getRDBRepositoryByClass(Attachment::class)
|
||||
->getById($attachmentId);
|
||||
|
||||
if (!$attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$copied = $this->getAttachmentRepository()->getCopiedAttachment($attachment);
|
||||
|
||||
$copied->set('parentType', $fieldData->getParentType());
|
||||
$copied->set('relatedType', $fieldData->getRelatedType());
|
||||
$copied->setTargetField($fieldData->getField());
|
||||
$copied->setRole(Attachment::ROLE_ATTACHMENT);
|
||||
|
||||
$this->getAttachmentRepository()->save($copied);
|
||||
|
||||
return $copied;
|
||||
}
|
||||
|
||||
private function getAttachmentRepository(): AttachmentRepository
|
||||
{
|
||||
/** @var AttachmentRepository */
|
||||
return $this->entityManager->getRepositoryByClass(Attachment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function moveUp(string $id, SearchParams $params): void
|
||||
{
|
||||
/** @var ?KnowledgeBaseArticle $entity */
|
||||
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::ENTITY_TYPE, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($entity)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$currentIndex = $entity->getOrder();
|
||||
|
||||
if (!is_int($currentIndex)) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
$query = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(KnowledgeBaseArticle::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->withSearchParams($params)
|
||||
->buildQueryBuilder()
|
||||
->where([
|
||||
'order<' => $currentIndex,
|
||||
])
|
||||
->order([
|
||||
['order', 'DESC'],
|
||||
])
|
||||
->build();
|
||||
|
||||
/** @var ?KnowledgeBaseArticle $previousEntity */
|
||||
$previousEntity = $this->entityManager
|
||||
->getRDBRepositoryByClass(KnowledgeBaseArticle::class)
|
||||
->clone($query)
|
||||
->findOne();
|
||||
|
||||
if (!$previousEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->set('order', $previousEntity->getOrder());
|
||||
|
||||
$previousEntity->set('order', $currentIndex);
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
$this->entityManager->saveEntity($previousEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function moveDown(string $id, SearchParams $params): void
|
||||
{
|
||||
/** @var ?KnowledgeBaseArticle $entity */
|
||||
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::ENTITY_TYPE, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($entity)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$currentIndex = $entity->getOrder();
|
||||
|
||||
if (!is_int($currentIndex)) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
$query = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(KnowledgeBaseArticle::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->withSearchParams($params)
|
||||
->buildQueryBuilder()
|
||||
->where([
|
||||
'order>' => $currentIndex,
|
||||
])
|
||||
->order([
|
||||
['order', 'ASC'],
|
||||
])
|
||||
->build();
|
||||
|
||||
/** @var ?KnowledgeBaseArticle $nextEntity */
|
||||
$nextEntity = $this->entityManager
|
||||
->getRDBRepositoryByClass(KnowledgeBaseArticle::class)
|
||||
->clone($query)
|
||||
->findOne();
|
||||
|
||||
if (!$nextEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->set('order', $nextEntity->getOrder());
|
||||
|
||||
$nextEntity->set('order', $currentIndex);
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
$this->entityManager->saveEntity($nextEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function moveToTop(string $id, SearchParams $params): void
|
||||
{
|
||||
/** @var ?KnowledgeBaseArticle $entity */
|
||||
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::ENTITY_TYPE, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($entity)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$currentIndex = $entity->getOrder();
|
||||
|
||||
if (!is_int($currentIndex)) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
$query = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(KnowledgeBaseArticle::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->withSearchParams($params)
|
||||
->buildQueryBuilder()
|
||||
->where([
|
||||
'order<' => $currentIndex,
|
||||
])
|
||||
->order([
|
||||
['order', 'ASC'],
|
||||
])
|
||||
->build();
|
||||
|
||||
/** @var ?KnowledgeBaseArticle $previousEntity */
|
||||
$previousEntity = $this->entityManager
|
||||
->getRDBRepositoryByClass(KnowledgeBaseArticle::class)
|
||||
->clone($query)
|
||||
->findOne();
|
||||
|
||||
if (!$previousEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->set('order', $previousEntity->getOrder() - 1);
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function moveToBottom(string $id, SearchParams $params): void
|
||||
{
|
||||
/** @var ?KnowledgeBaseArticle $entity */
|
||||
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::ENTITY_TYPE, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($entity)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$currentIndex = $entity->getOrder();
|
||||
|
||||
if (!is_int($currentIndex)) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
$query = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(KnowledgeBaseArticle::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->withSearchParams($params)
|
||||
->buildQueryBuilder()
|
||||
->where([
|
||||
'order>' => $currentIndex,
|
||||
])
|
||||
->order([
|
||||
['order', 'DESC'],
|
||||
])
|
||||
->build();
|
||||
|
||||
/** @var ?KnowledgeBaseArticle $nextEntity */
|
||||
$nextEntity = $this->entityManager
|
||||
->getRDBRepositoryByClass(KnowledgeBaseArticle::class)
|
||||
->clone($query)
|
||||
->findOne();
|
||||
|
||||
if (!$nextEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->set('order', $nextEntity->getOrder() + 1);
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
}
|
||||
}
|
||||
46
application/Espo/Modules/Crm/Tools/Lead/Convert/Params.php
Normal file
46
application/Espo/Modules/Crm/Tools/Lead/Convert/Params.php
Normal 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\Modules\Crm\Tools\Lead\Convert;
|
||||
|
||||
class Params
|
||||
{
|
||||
private bool $skipDuplicateCheck;
|
||||
|
||||
public function __construct(
|
||||
bool $skipDuplicateCheck
|
||||
) {
|
||||
$this->skipDuplicateCheck = $skipDuplicateCheck;
|
||||
}
|
||||
|
||||
public function skipDuplicateCheck(): bool
|
||||
{
|
||||
return $this->skipDuplicateCheck;
|
||||
}
|
||||
}
|
||||
85
application/Espo/Modules/Crm/Tools/Lead/Convert/Values.php
Normal file
85
application/Espo/Modules/Crm/Tools/Lead/Convert/Values.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?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\Modules\Crm\Tools\Lead\Convert;
|
||||
|
||||
use Espo\Core\Utils\ObjectUtil;
|
||||
use stdClass;
|
||||
use UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* Raw attribute values of multiple records.
|
||||
*
|
||||
* Immutable.
|
||||
*/
|
||||
class Values
|
||||
{
|
||||
/** @var array<string, stdClass> */
|
||||
private array $data = [];
|
||||
|
||||
public static function create(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
public function has(string $entityType): bool
|
||||
{
|
||||
return array_key_exists($entityType, $this->data);
|
||||
}
|
||||
|
||||
public function get(string $entityType): stdClass
|
||||
{
|
||||
$data = $this->data[$entityType] ?? null;
|
||||
|
||||
if ($data === null) {
|
||||
throw new UnexpectedValueException();
|
||||
}
|
||||
|
||||
return ObjectUtil::clone($data);
|
||||
}
|
||||
|
||||
public function with(string $entityType, stdClass $data): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->data[$entityType] = ObjectUtil::clone($data);
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function getRaw(): stdClass
|
||||
{
|
||||
$data = (object) [];
|
||||
|
||||
foreach ($this->data as $entityType => $item) {
|
||||
$data->$entityType = ObjectUtil::clone($item);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
608
application/Espo/Modules/Crm/Tools/Lead/ConvertService.php
Normal file
608
application/Espo/Modules/Crm/Tools/Lead/ConvertService.php
Normal file
@@ -0,0 +1,608 @@
|
||||
<?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\Modules\Crm\Tools\Lead;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Conflict;
|
||||
use Espo\Core\Exceptions\ConflictSilent;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Record\CreateParams;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Utils\FieldUtil;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\Attachment;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Entities\Account;
|
||||
use Espo\Modules\Crm\Entities\Call;
|
||||
use Espo\Modules\Crm\Entities\Contact;
|
||||
use Espo\Modules\Crm\Entities\Document;
|
||||
use Espo\Modules\Crm\Entities\Lead;
|
||||
use Espo\Modules\Crm\Entities\Meeting;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\Modules\Crm\Tools\Lead\Convert\Params;
|
||||
use Espo\Modules\Crm\Tools\Lead\Convert\Values;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Espo\Repositories\Attachment as AttachmentRepository;
|
||||
use Espo\Tools\Stream\Service as StreamService;
|
||||
|
||||
class ConvertService
|
||||
{
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private ServiceContainer $recordServiceContainer,
|
||||
private EntityManager $entityManager,
|
||||
private User $user,
|
||||
private StreamService $streamService,
|
||||
private Metadata $metadata,
|
||||
private FieldUtil $fieldUtil,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Convert a lead.
|
||||
*
|
||||
* @throws Forbidden
|
||||
* @throws Conflict
|
||||
* @throws NotFound
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function convert(string $id, Values $records, Params $params): Lead
|
||||
{
|
||||
$lead = $this->getLead($id);
|
||||
|
||||
if (!$params->skipDuplicateCheck()) {
|
||||
$this->processDuplicateCheck($records);
|
||||
}
|
||||
|
||||
$account = $this->processAccount(
|
||||
lead: $lead,
|
||||
records: $records,
|
||||
);
|
||||
|
||||
$contact = $this->processContact(
|
||||
lead: $lead,
|
||||
records: $records,
|
||||
account: $account,
|
||||
);
|
||||
|
||||
$account ??= $this->getSelectedAccount($contact);
|
||||
|
||||
$opportunity = $this->processOpportunity(
|
||||
lead: $lead,
|
||||
records: $records,
|
||||
account: $account,
|
||||
contact: $contact,
|
||||
);
|
||||
|
||||
$lead->setStatus(Lead::STATUS_CONVERTED);
|
||||
|
||||
$this->entityManager->saveEntity($lead);
|
||||
|
||||
$this->processLinks($lead, $account, $contact, $opportunity);
|
||||
$this->processStream($lead, $account, $contact, $opportunity);
|
||||
|
||||
return $lead;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get values for the conversion form.
|
||||
*
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function getValues(string $id): Values
|
||||
{
|
||||
$lead = $this->getLead($id);
|
||||
|
||||
$values = Values::create();
|
||||
|
||||
/** @var string[] $entityList */
|
||||
$entityList = $this->metadata->get('entityDefs.Lead.convertEntityList') ?? [];
|
||||
|
||||
$ignoreAttributeList = [
|
||||
Field::CREATED_AT,
|
||||
Field::MODIFIED_AT,
|
||||
Field::MODIFIED_BY . 'Id',
|
||||
Field::MODIFIED_BY . 'Name',
|
||||
Field::CREATED_BY . 'Id',
|
||||
Field::MODIFIED_BY . 'Name',
|
||||
];
|
||||
|
||||
/** @var array<string, array<string, string>> $convertFieldsDefs */
|
||||
$convertFieldsDefs = $this->metadata->get('entityDefs.Lead.convertFields') ?? [];
|
||||
|
||||
foreach ($entityList as $entityType) {
|
||||
if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_CREATE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attributes = [];
|
||||
$fieldMap = [];
|
||||
|
||||
/** @var string[] $fieldList */
|
||||
$fieldList = array_keys($this->metadata->get('entityDefs.Lead.fields', []));
|
||||
|
||||
foreach ($fieldList as $field) {
|
||||
if (!$this->metadata->get("entityDefs.$entityType.fields.$field")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
$this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type'])
|
||||
!==
|
||||
$this->metadata->get(['entityDefs', 'Lead', 'fields', $field, 'type'])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldMap[$field] = $field;
|
||||
}
|
||||
|
||||
if (array_key_exists($entityType, $convertFieldsDefs)) {
|
||||
foreach ($convertFieldsDefs[$entityType] as $field => $leadField) {
|
||||
$fieldMap[$field] = $leadField;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($fieldMap as $field => $leadField) {
|
||||
$type = $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type']);
|
||||
|
||||
if (in_array($type, [FieldType::FILE, FieldType::IMAGE])) {
|
||||
$attachment = $lead->get($leadField);
|
||||
|
||||
if ($attachment) {
|
||||
$attachment = $this->getAttachmentRepository()->getCopiedAttachment($attachment);
|
||||
|
||||
$idAttribute = $field . 'Id';
|
||||
$nameAttribute = $field . 'Name';
|
||||
|
||||
$attributes[$idAttribute] = $attachment->getId();
|
||||
$attributes[$nameAttribute] = $attachment->getName();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === FieldType::ATTACHMENT_MULTIPLE) {
|
||||
$attachmentList = $lead->get($leadField);
|
||||
|
||||
if (count($attachmentList)) {
|
||||
$idList = [];
|
||||
$nameHash = (object) [];
|
||||
$typeHash = (object) [];
|
||||
|
||||
foreach ($attachmentList as $attachment) {
|
||||
$attachment = $this->getAttachmentRepository()->getCopiedAttachment($attachment);
|
||||
|
||||
$idList[] = $attachment->getId();
|
||||
|
||||
$nameHash->{$attachment->getId()} = $attachment->getName();
|
||||
$typeHash->{$attachment->getId()} = $attachment->getType();
|
||||
}
|
||||
|
||||
$attributes[$field . 'Ids'] = $idList;
|
||||
$attributes[$field . 'Names'] = $nameHash;
|
||||
$attributes[$field . 'Types'] = $typeHash;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === FieldType::LINK_MULTIPLE) {
|
||||
$attributes[$field . 'Ids'] = $lead->get($leadField . 'Ids');
|
||||
$attributes[$field . 'Names'] = $lead->get($leadField . 'Names');
|
||||
$attributes[$field . 'Columns'] = $lead->get($leadField . 'Columns');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$leadAttributeList = $this->fieldUtil->getAttributeList(Lead::ENTITY_TYPE, $leadField);
|
||||
|
||||
$attributeList = $this->fieldUtil->getAttributeList($entityType, $field);
|
||||
|
||||
if (count($attributeList) !== count($leadAttributeList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($attributeList as $i => $attribute) {
|
||||
if (in_array($attribute, $ignoreAttributeList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$leadAttribute = $leadAttributeList[$i] ?? null;
|
||||
|
||||
if (!$leadAttribute || !$lead->has($leadAttribute)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attributes[$attribute] = $lead->get($leadAttribute);
|
||||
}
|
||||
}
|
||||
|
||||
$values = $values->with($entityType, (object) $attributes);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function getAttachmentRepository(): AttachmentRepository
|
||||
{
|
||||
/** @var AttachmentRepository */
|
||||
return $this->entityManager->getRepository(Attachment::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws Conflict
|
||||
*/
|
||||
private function processAccount(Lead $lead, Values $records): ?Account
|
||||
{
|
||||
if (!$records->has(Account::ENTITY_TYPE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkScope(Account::ENTITY_TYPE, Acl\Table::ACTION_CREATE)) {
|
||||
throw new Forbidden("No 'create' access for Account.");
|
||||
}
|
||||
|
||||
$values = $records->get(Account::ENTITY_TYPE);
|
||||
|
||||
$service = $this->recordServiceContainer->getByClass(Account::class);
|
||||
|
||||
$account = $service->create($values, CreateParams::create()->withSkipDuplicateCheck());
|
||||
|
||||
$lead->setCreatedAccount($account);
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws Conflict
|
||||
*/
|
||||
private function processContact(
|
||||
Lead $lead,
|
||||
Values $records,
|
||||
?Account $account,
|
||||
): ?Contact {
|
||||
|
||||
if (!$records->has(Contact::ENTITY_TYPE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkScope(Contact::ENTITY_TYPE, Acl\Table::ACTION_CREATE)) {
|
||||
throw new Forbidden("No 'create' access for Contact.");
|
||||
}
|
||||
|
||||
$values = $records->get(Contact::ENTITY_TYPE);
|
||||
|
||||
if ($account) {
|
||||
$values->accountId = $account->getId();
|
||||
}
|
||||
|
||||
$service = $this->recordServiceContainer->getByClass(Contact::class);
|
||||
|
||||
$contact = $service->create($values, CreateParams::create()->withSkipDuplicateCheck());
|
||||
|
||||
$lead->set('createdContactId', $contact->getId());
|
||||
|
||||
if (
|
||||
!$account &&
|
||||
$contact->getAccount() &&
|
||||
!$contact->getAccount()->get('originalLeadId')
|
||||
) {
|
||||
$lead->setCreatedAccount($contact->getAccount());
|
||||
}
|
||||
|
||||
return $contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws Conflict
|
||||
*/
|
||||
private function processOpportunity(
|
||||
Lead $lead,
|
||||
Values $records,
|
||||
?Account $account,
|
||||
?Contact $contact,
|
||||
): ?Opportunity {
|
||||
|
||||
if (!$records->has(Opportunity::ENTITY_TYPE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_CREATE)) {
|
||||
throw new Forbidden("No 'create' access for Opportunity.");
|
||||
}
|
||||
|
||||
$values = $records->get(Opportunity::ENTITY_TYPE);
|
||||
|
||||
if ($account) {
|
||||
$values->accountId = $account->getId();
|
||||
}
|
||||
|
||||
if ($contact) {
|
||||
$values->contactId = $contact->getId();
|
||||
}
|
||||
|
||||
$service = $this->recordServiceContainer->getByClass(Opportunity::class);
|
||||
|
||||
$opportunity = $service->create($values, CreateParams::create()->withSkipDuplicateCheck());
|
||||
|
||||
if ($contact) {
|
||||
$this->entityManager
|
||||
->getRelation($contact, 'opportunities')
|
||||
->relate($opportunity);
|
||||
}
|
||||
|
||||
$lead->set('createdOpportunityId', $opportunity->getId());
|
||||
|
||||
return $opportunity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getLead(string $id): Lead
|
||||
{
|
||||
$lead = $this->recordServiceContainer
|
||||
->getByClass(Lead::class)
|
||||
->getEntity($id);
|
||||
|
||||
if (!$lead) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($lead)) {
|
||||
throw new Forbidden("No edit access.");
|
||||
}
|
||||
|
||||
return $lead;
|
||||
}
|
||||
|
||||
private function processLinks(
|
||||
Lead $lead,
|
||||
?Account $account,
|
||||
?Contact $contact,
|
||||
?Opportunity $opportunity,
|
||||
): void {
|
||||
|
||||
$leadRepository = $this->entityManager->getRDBRepositoryByClass(Lead::class);
|
||||
|
||||
/** @var Collection<Meeting> $meetings */
|
||||
$meetings = $leadRepository
|
||||
->getRelation($lead, 'meetings')
|
||||
->select([Attribute::ID, 'parentId', 'parentType'])
|
||||
->find();
|
||||
|
||||
foreach ($meetings as $meeting) {
|
||||
if ($contact && $contact->hasId()) {
|
||||
$this->entityManager
|
||||
->getRDBRepository(Meeting::ENTITY_TYPE)
|
||||
->getRelation($meeting, 'contacts')
|
||||
->relate($contact);
|
||||
}
|
||||
|
||||
if ($opportunity && $opportunity->hasId()) {
|
||||
$meeting->set('parentId', $opportunity->getId());
|
||||
$meeting->set('parentType', Opportunity::ENTITY_TYPE);
|
||||
|
||||
$this->entityManager->saveEntity($meeting);
|
||||
} else if ($account && $account->hasId()) {
|
||||
$meeting->set('parentId', $account->getId());
|
||||
$meeting->set('parentType', Account::ENTITY_TYPE);
|
||||
|
||||
$this->entityManager->saveEntity($meeting);
|
||||
}
|
||||
}
|
||||
|
||||
/** @var Collection<Call> $calls */
|
||||
$calls = $leadRepository
|
||||
->getRelation($lead, 'calls')
|
||||
->select([Attribute::ID, 'parentId', 'parentType'])
|
||||
->find();
|
||||
|
||||
foreach ($calls as $call) {
|
||||
if ($contact && $contact->hasId()) {
|
||||
$this->entityManager
|
||||
->getRelation($call, 'contacts')
|
||||
->relate($contact);
|
||||
}
|
||||
|
||||
if ($opportunity && $opportunity->hasId()) {
|
||||
$call->set('parentId', $opportunity->getId());
|
||||
$call->set('parentType', Opportunity::ENTITY_TYPE);
|
||||
|
||||
$this->entityManager->saveEntity($call);
|
||||
} else if ($account && $account->hasId()) {
|
||||
$call->set('parentId', $account->getId());
|
||||
$call->set('parentType', Account::ENTITY_TYPE);
|
||||
|
||||
$this->entityManager->saveEntity($call);
|
||||
}
|
||||
}
|
||||
|
||||
/** @var Collection<Email> $emails */
|
||||
$emails = $leadRepository
|
||||
->getRelation($lead, 'emails')
|
||||
->select([Attribute::ID, 'parentId', 'parentType'])
|
||||
->find();
|
||||
|
||||
foreach ($emails as $email) {
|
||||
if ($opportunity && $opportunity->hasId()) {
|
||||
$email->set('parentId', $opportunity->getId());
|
||||
$email->set('parentType', Opportunity::ENTITY_TYPE);
|
||||
|
||||
$this->entityManager->saveEntity($email);
|
||||
} else if ($account && $account->hasId()) {
|
||||
$email->set('parentId', $account->getId());
|
||||
$email->set('parentType', Account::ENTITY_TYPE);
|
||||
|
||||
$this->entityManager->saveEntity($email);
|
||||
}
|
||||
}
|
||||
|
||||
/** @var Collection<Document> $documents */
|
||||
$documents = $leadRepository
|
||||
->getRelation($lead, 'documents')
|
||||
->select([Attribute::ID])
|
||||
->find();
|
||||
|
||||
foreach ($documents as $document) {
|
||||
if ($account && $account->hasId()) {
|
||||
$this->entityManager
|
||||
->getRelation($document, 'accounts')
|
||||
->relate($account);
|
||||
}
|
||||
|
||||
if ($opportunity && $opportunity->hasId()) {
|
||||
$this->entityManager
|
||||
->getRelation($document, 'opportunities')
|
||||
->relate($opportunity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function processStream(
|
||||
Lead $lead,
|
||||
?Account $account,
|
||||
?Contact $contact,
|
||||
?Opportunity $opportunity
|
||||
): void {
|
||||
|
||||
if (!$this->streamService->checkIsFollowed($lead, $this->user->getId())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($opportunity && $opportunity->hasId()) {
|
||||
$this->streamService->followEntity($opportunity, $this->user->getId());
|
||||
}
|
||||
|
||||
if ($account && $account->hasId()) {
|
||||
$this->streamService->followEntity($account, $this->user->getId());
|
||||
}
|
||||
|
||||
if ($contact && $contact->hasId()) {
|
||||
$this->streamService->followEntity($contact, $this->user->getId());
|
||||
}
|
||||
}
|
||||
|
||||
private function getSelectedAccount(?Contact $contact): ?Account
|
||||
{
|
||||
if (!$contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$contact->getAccount()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$account = $this->entityManager
|
||||
->getRDBRepositoryByClass(Account::class)
|
||||
->getById($contact->getAccount()->getId());
|
||||
|
||||
if ($account && !$this->acl->checkEntityRead($account)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Conflict
|
||||
*/
|
||||
private function processDuplicateCheck(Values $records): void
|
||||
{
|
||||
$duplicateList = [];
|
||||
|
||||
if ($records->has(Account::ENTITY_TYPE)) {
|
||||
$accountService = $this->recordServiceContainer->getByClass(Account::class);
|
||||
|
||||
$account = $this->entityManager->getRDBRepositoryByClass(Account::class)->getNew();
|
||||
$account->set($records->get(Account::ENTITY_TYPE));
|
||||
|
||||
foreach ($accountService->findDuplicates($account) ?? [] as $e) {
|
||||
$duplicateList[] = (object) [
|
||||
'id' => $e->getId(),
|
||||
'name' => $e->getName(),
|
||||
'_entityType' => $e->getEntityType(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($records->has(Contact::ENTITY_TYPE)) {
|
||||
$contactService = $this->recordServiceContainer->getByClass(Contact::class);
|
||||
|
||||
$contact = $this->entityManager->getRDBRepositoryByClass(Contact::class)->getNew();
|
||||
$contact->set($records->get(Contact::ENTITY_TYPE));
|
||||
|
||||
foreach ($contactService->findDuplicates($contact) ?? [] as $e) {
|
||||
$duplicateList[] = (object) [
|
||||
'id' => $e->getId(),
|
||||
'name' => $e->getName(),
|
||||
'_entityType' => $e->getEntityType(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($records->has(Opportunity::ENTITY_TYPE)) {
|
||||
$opportunityService = $this->recordServiceContainer->getByClass(Opportunity::class);
|
||||
|
||||
$opportunity = $this->entityManager->getRDBRepositoryByClass(Opportunity::class)->getNew();
|
||||
$opportunity->set($records->get(Opportunity::ENTITY_TYPE));
|
||||
|
||||
foreach ($opportunityService->findDuplicates($opportunity) ?? [] as $e) {
|
||||
$duplicateList[] = (object) [
|
||||
'id' => $e->getId(),
|
||||
'name' => $e->getName(),
|
||||
'_entityType' => $e->getEntityType(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!count($duplicateList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ConflictSilent::createWithBody('duplicate', Json::encode($duplicateList));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Modules\Crm\Tools\MassEmail\UnsubscribeService;
|
||||
|
||||
/** @noinspection PhpUnused */
|
||||
class DeleteUnsubscribe implements Action
|
||||
{
|
||||
public function __construct(private UnsubscribeService $service) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id');
|
||||
$hash = $request->getRouteParam('hash');
|
||||
$emailAddress = $request->getRouteParam('emailAddress');
|
||||
|
||||
if ($hash && $emailAddress) {
|
||||
$this->service->subscribeAgainWithHash($emailAddress, $hash);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
|
||||
if (!$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$this->service->subscribeAgain($id);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Modules\Crm\Tools\MassEmail\UnsubscribeService;
|
||||
|
||||
/** @noinspection PhpUnused */
|
||||
class PostUnsubscribe implements Action
|
||||
{
|
||||
public function __construct(private UnsubscribeService $service) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id');
|
||||
$hash = $request->getRouteParam('hash');
|
||||
$emailAddress = $request->getRouteParam('emailAddress');
|
||||
|
||||
if ($hash && $emailAddress) {
|
||||
$this->service->unsubscribeWithHash($emailAddress, $hash);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
|
||||
if (!$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$this->service->unsubscribe($id);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail;
|
||||
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Modules\Crm\Entities\Campaign;
|
||||
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Data;
|
||||
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers;
|
||||
|
||||
class DefaultMessageHeadersPreparator implements MessageHeadersPreparator
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private Config\ApplicationConfig $applicationConfig,
|
||||
) {}
|
||||
|
||||
public function prepare(Headers $headers, Data $data): void
|
||||
{
|
||||
$headers->addTextHeader('X-Queue-Item-Id', $data->getId());
|
||||
$headers->addTextHeader('Precedence', 'bulk');
|
||||
|
||||
$campaignType = $this->getCampaignType($data);
|
||||
|
||||
if (
|
||||
$campaignType === Campaign::TYPE_INFORMATIONAL_EMAIL ||
|
||||
$campaignType === Campaign::TYPE_NEWSLETTER
|
||||
) {
|
||||
$headers->addTextHeader('Auto-Submitted', 'auto-generated');
|
||||
$headers->addTextHeader('X-Auto-Response-Suppress', 'AutoReply');
|
||||
}
|
||||
|
||||
$this->addMandatoryOptOut($headers, $data);
|
||||
}
|
||||
|
||||
private function getSiteUrl(): string
|
||||
{
|
||||
$url = $this->config->get('massEmailSiteUrl') ?? $this->applicationConfig->getSiteUrl();
|
||||
|
||||
return rtrim($url, '/');
|
||||
}
|
||||
|
||||
private function addMandatoryOptOut(Headers $headers, Data $data): void
|
||||
{
|
||||
if ($this->getCampaignType($data) === Campaign::TYPE_INFORMATIONAL_EMAIL) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->config->get('massEmailDisableMandatoryOptOutLink')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $data->getId();
|
||||
|
||||
$url = "{$this->getSiteUrl()}/api/v1/Campaign/unsubscribe/$id";
|
||||
|
||||
$headers->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
|
||||
$headers->addTextHeader('List-Unsubscribe', "<$url>");
|
||||
}
|
||||
|
||||
private function getCampaignType(Data $data): ?string
|
||||
{
|
||||
return $data->getQueueItem()->getMassEmail()?->getCampaign()?->getType();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail;
|
||||
|
||||
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Data;
|
||||
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers;
|
||||
|
||||
/**
|
||||
* Applies additional headers to a mass email message.
|
||||
*/
|
||||
interface MessageHeadersPreparator
|
||||
{
|
||||
public function prepare(Headers $headers, Data $data): void;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail\MessagePreparator;
|
||||
|
||||
use Espo\Core\Mail\SenderParams;
|
||||
use Espo\Modules\Crm\Entities\EmailQueueItem;
|
||||
|
||||
class Data
|
||||
{
|
||||
public function __construct(
|
||||
private string $id,
|
||||
private SenderParams $senderParams,
|
||||
private EmailQueueItem $queueItem,
|
||||
) {}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/** @noinspection PhpUnused */
|
||||
public function getSenderParams(): SenderParams
|
||||
{
|
||||
return $this->senderParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 9.1.0
|
||||
*/
|
||||
public function getQueueItem(): EmailQueueItem
|
||||
{
|
||||
return $this->queueItem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail\MessagePreparator;
|
||||
|
||||
use Espo\Core\Mail\Sender;
|
||||
|
||||
class Headers
|
||||
{
|
||||
public function __construct(
|
||||
private Sender $sender,
|
||||
) {}
|
||||
|
||||
public function addTextHeader(string $name, string $value): void
|
||||
{
|
||||
$this->sender->withAddedHeader($name, $value);
|
||||
}
|
||||
}
|
||||
255
application/Espo/Modules/Crm/Tools/MassEmail/QueueCreator.php
Normal file
255
application/Espo/Modules/Crm/Tools/MassEmail/QueueCreator.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail;
|
||||
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Modules\Crm\Entities\Campaign;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Espo\Repositories\EmailAddress as EmailAddressRepository;
|
||||
use Espo\Entities\EmailAddress;
|
||||
use Espo\Modules\Crm\Entities\MassEmail;
|
||||
use Espo\Modules\Crm\Entities\EmailQueueItem;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use stdClass;
|
||||
|
||||
class QueueCreator
|
||||
{
|
||||
private const ERASED_PREFIX = 'ERASED:';
|
||||
|
||||
/** @var string[] */
|
||||
protected array $targetLinkList;
|
||||
|
||||
public function __construct(
|
||||
protected EntityManager $entityManager,
|
||||
private Metadata $metadata,
|
||||
private Log $log,
|
||||
) {
|
||||
$this->targetLinkList = $this->metadata->get(['scopes', 'TargetList', 'targetLinkList']) ?? [];
|
||||
}
|
||||
|
||||
private function cleanupQueueItems(MassEmail $massEmail): void
|
||||
{
|
||||
$delete = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->delete()
|
||||
->from(EmailQueueItem::ENTITY_TYPE)
|
||||
->where([
|
||||
'massEmailId' => $massEmail->getId(),
|
||||
'status' => [
|
||||
EmailQueueItem::STATUS_PENDING,
|
||||
EmailQueueItem::STATUS_FAILED,
|
||||
],
|
||||
])
|
||||
->build();
|
||||
|
||||
$this->entityManager->getQueryExecutor()->execute($delete);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Entity> $additionalTargetList
|
||||
* @throws Error
|
||||
*/
|
||||
public function create(MassEmail $massEmail, bool $isTest = false, iterable $additionalTargetList = []): void
|
||||
{
|
||||
if (!$isTest && $massEmail->getStatus() !== MassEmail::STATUS_PENDING) {
|
||||
throw new Error("Mass Email {$massEmail->getId()} should has status 'Pending'.");
|
||||
}
|
||||
|
||||
if ($this->toSkipAsInactive($massEmail, $isTest)) {
|
||||
$this->log->notice("Skipping mass email {id} queue creation for inactive campaign.", [
|
||||
'id' => $massEmail->getId(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$withOptedOut = $massEmail->getCampaign()?->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL;
|
||||
|
||||
if (!$isTest) {
|
||||
$this->cleanupQueueItems($massEmail);
|
||||
}
|
||||
|
||||
$itemList = [];
|
||||
|
||||
if (!$isTest) {
|
||||
$itemList = $this->getItemList($massEmail, $withOptedOut);
|
||||
}
|
||||
|
||||
foreach ($additionalTargetList as $record) {
|
||||
$item = $record->getValueMap();
|
||||
|
||||
$item->entityType = $record->getEntityType();
|
||||
|
||||
$itemList[] = $item;
|
||||
}
|
||||
|
||||
foreach ($itemList as $item) {
|
||||
$emailAddress = $item->emailAddress ?? null;
|
||||
|
||||
if (!$emailAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($emailAddress, self::ERASED_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$emailAddressRecord = $this->getEmailAddressRepository()->getByAddress($emailAddress);
|
||||
|
||||
if ($emailAddressRecord && !$withOptedOut && $emailAddressRecord->isOptedOut()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($emailAddressRecord && $emailAddressRecord->isInvalid()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$queueItem = $this->entityManager->getNewEntity(EmailQueueItem::ENTITY_TYPE);
|
||||
|
||||
$queueItem->set([
|
||||
'massEmailId' => $massEmail->getId(),
|
||||
'status' => EmailQueueItem::STATUS_PENDING,
|
||||
'targetId' => $item->id,
|
||||
'targetType' => $item->entityType,
|
||||
'isTest' => $isTest,
|
||||
]);
|
||||
|
||||
$this->entityManager->saveEntity($queueItem);
|
||||
}
|
||||
|
||||
if ($isTest) {
|
||||
return;
|
||||
}
|
||||
|
||||
$massEmail->setStatus(MassEmail::STATUS_IN_PROCESS);
|
||||
|
||||
if ($itemList === []) {
|
||||
$massEmail->setStatus(MassEmail::STATUS_COMPLETE);
|
||||
}
|
||||
|
||||
$this->entityManager->saveEntity($massEmail);
|
||||
}
|
||||
|
||||
private function getEmailAddressRepository(): EmailAddressRepository
|
||||
{
|
||||
/** @var EmailAddressRepository */
|
||||
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
private function toSkipAsInactive(MassEmail $massEmail, bool $isTest): bool
|
||||
{
|
||||
return !$isTest &&
|
||||
$massEmail->getCampaign() &&
|
||||
$massEmail->getCampaign()->getStatus() === Campaign::STATUS_INACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return stdClass[]
|
||||
*/
|
||||
private function getItemList(MassEmail $massEmail, bool $withOptedOut): array
|
||||
{
|
||||
$metTargetHash = [];
|
||||
$metEmailAddressHash = [];
|
||||
|
||||
$itemList = [];
|
||||
|
||||
foreach ($massEmail->getExcludingTargetLists() as $excludingTargetList) {
|
||||
foreach ($this->targetLinkList as $link) {
|
||||
$targets = $this->entityManager
|
||||
->getRelation($excludingTargetList, $link)
|
||||
->sth()
|
||||
->select([Attribute::ID, Field::EMAIL_ADDRESS])
|
||||
->find();
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$hashId = $target->getEntityType() . '-' . $target->getId();
|
||||
|
||||
$metTargetHash[$hashId] = true;
|
||||
|
||||
$emailAddress = $target->get(Field::EMAIL_ADDRESS);
|
||||
|
||||
if ($emailAddress) {
|
||||
$metEmailAddressHash[$emailAddress] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($massEmail->getTargetLists() as $targetList) {
|
||||
foreach ($this->targetLinkList as $link) {
|
||||
$where = [];
|
||||
|
||||
if (!$withOptedOut) {
|
||||
$where = ['@relation.optedOut' => false];
|
||||
}
|
||||
|
||||
$records = $this->entityManager
|
||||
->getRelation($targetList, $link)
|
||||
->select([Attribute::ID, Field::EMAIL_ADDRESS])
|
||||
->sth()
|
||||
->where($where)
|
||||
->find();
|
||||
|
||||
foreach ($records as $record) {
|
||||
$hashId = $record->getEntityType() . '-' . $record->getId();
|
||||
|
||||
$emailAddress = $record->get(Field::EMAIL_ADDRESS);
|
||||
|
||||
if (!$emailAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($metEmailAddressHash[$emailAddress])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($metTargetHash[$hashId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = $record->getValueMap();
|
||||
|
||||
$item->entityType = $record->getEntityType();
|
||||
|
||||
$itemList[] = $item;
|
||||
|
||||
$metTargetHash[$hashId] = true;
|
||||
$metEmailAddressHash[$emailAddress] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $itemList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail;
|
||||
|
||||
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers;
|
||||
use Laminas\Mail\Message;
|
||||
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Core\Mail\ConfigDataProvider;
|
||||
use Espo\ORM\EntityCollection;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Tools\EmailTemplate\Result;
|
||||
use Espo\Core\Mail\Account\GroupAccount\AccountFactory;
|
||||
use Espo\Core\Mail\Exceptions\NoSmtp;
|
||||
use Espo\Core\Mail\SenderParams;
|
||||
use Espo\Core\Mail\SmtpParams;
|
||||
use Espo\Entities\Attachment;
|
||||
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Data;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\Entities\EmailTemplate;
|
||||
use Espo\Repositories\EmailAddress as EmailAddressRepository;
|
||||
use Espo\Entities\EmailAddress;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Mail\EmailSender;
|
||||
use Espo\Core\Mail\Sender;
|
||||
use Espo\Core\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Modules\Crm\Entities\Campaign;
|
||||
use Espo\Modules\Crm\Entities\CampaignTrackingUrl;
|
||||
use Espo\Modules\Crm\Entities\EmailQueueItem;
|
||||
use Espo\Modules\Crm\Entities\MassEmail;
|
||||
use Espo\Modules\Crm\Tools\Campaign\LogService as CampaignService;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Tools\EmailTemplate\Data as TemplateData;
|
||||
use Espo\Tools\EmailTemplate\Params as TemplateParams;
|
||||
use Espo\Tools\EmailTemplate\Processor as TemplateProcessor;
|
||||
|
||||
use Exception;
|
||||
|
||||
class SendingProcessor
|
||||
{
|
||||
private const MAX_ATTEMPT_COUNT = 3;
|
||||
private const MAX_PER_HOUR_COUNT = 10000;
|
||||
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private EntityManager $entityManager,
|
||||
private Language $defaultLanguage,
|
||||
private EmailSender $emailSender,
|
||||
private Log $log,
|
||||
private AccountFactory $accountFactory,
|
||||
private CampaignService $campaignService,
|
||||
private MessageHeadersPreparator $headersPreparator,
|
||||
private TemplateProcessor $templateProcessor,
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private Config\ApplicationConfig $applicationConfig,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
* @throws NoSmtp
|
||||
*/
|
||||
public function process(MassEmail $massEmail, bool $isTest = false): void
|
||||
{
|
||||
if ($this->toSkipAsInactive($massEmail, $isTest)) {
|
||||
$this->log->notice("Skipping mass email {id} queue for inactive campaign.", [
|
||||
'id' => $massEmail->getId(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$maxSize = 0;
|
||||
|
||||
if ($this->handleMaxSize($isTest, $maxSize)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emailTemplate = $massEmail->getEmailTemplate();
|
||||
|
||||
if (!$emailTemplate) {
|
||||
$this->setFailed($massEmail);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$attachmentList = $emailTemplate->getAttachments();
|
||||
[$smtpParams, $senderParams] = $this->getSenderParams($massEmail);
|
||||
$queueItemList = $this->getQueueItems($massEmail, $isTest, $maxSize);
|
||||
|
||||
foreach ($queueItemList as $queueItem) {
|
||||
$this->sendQueueItem(
|
||||
queueItem: $queueItem,
|
||||
massEmail: $massEmail,
|
||||
emailTemplate: $emailTemplate,
|
||||
attachmentList: $attachmentList,
|
||||
isTest: $isTest,
|
||||
smtpParams: $smtpParams,
|
||||
senderParams: $senderParams,
|
||||
);
|
||||
}
|
||||
|
||||
if ($isTest) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->getCountLeft($massEmail) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setComplete($massEmail);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<CampaignTrackingUrl> $trackingUrlList
|
||||
*/
|
||||
private function getPreparedEmail(
|
||||
EmailQueueItem $queueItem,
|
||||
MassEmail $massEmail,
|
||||
EmailTemplate $emailTemplate,
|
||||
Entity $target,
|
||||
iterable $trackingUrlList = []
|
||||
): ?Email {
|
||||
|
||||
$emailAddress = $target->get(Field::EMAIL_ADDRESS);
|
||||
|
||||
if (!$emailAddress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$emailData = $this->templateProcessor->process(
|
||||
$emailTemplate,
|
||||
TemplateParams::create()
|
||||
->withApplyAcl(false) // @todo Revise.
|
||||
->withCopyAttachments(false), // @todo Revise.
|
||||
TemplateData::create()
|
||||
->withParent($target)
|
||||
);
|
||||
|
||||
$body = $this->prepareBody($massEmail, $queueItem, $emailData, $trackingUrlList);
|
||||
|
||||
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
|
||||
|
||||
$email
|
||||
->addToAddress($emailAddress)
|
||||
->setSubject($emailData->getSubject())
|
||||
->setBody($body)
|
||||
->setIsHtml($emailData->isHtml())
|
||||
->setAttachmentIdList($emailData->getAttachmentIdList());
|
||||
|
||||
if ($massEmail->getFromAddress()) {
|
||||
$email->setFromAddress($massEmail->getFromAddress());
|
||||
}
|
||||
|
||||
$replyToAddress = $massEmail->getReplyToAddress();
|
||||
|
||||
if ($replyToAddress) {
|
||||
$email->addReplyToAddress($replyToAddress);
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function prepareQueueItemMessage(
|
||||
EmailQueueItem $queueItem,
|
||||
Sender $sender,
|
||||
SenderParams $senderParams,
|
||||
): void {
|
||||
|
||||
$id = $queueItem->getId();
|
||||
|
||||
$headers = new Headers($sender);
|
||||
|
||||
$this->headersPreparator->prepare($headers, new Data($id, $senderParams, $queueItem));
|
||||
|
||||
$fromAddress = $senderParams->getFromAddress();
|
||||
|
||||
if (
|
||||
$this->config->get('massEmailVerp') &&
|
||||
$fromAddress &&
|
||||
strpos($fromAddress, '@')
|
||||
) {
|
||||
$bounceAddress = explode('@', $fromAddress)[0] . '+bounce-qid-' . $id .
|
||||
'@' . explode('@', $fromAddress)[1];
|
||||
|
||||
$sender->withEnvelopeFromAddress($bounceAddress);
|
||||
}
|
||||
}
|
||||
|
||||
private function setFailed(MassEmail $massEmail): void
|
||||
{
|
||||
$massEmail->setStatus(MassEmail::STATUS_FAILED);
|
||||
|
||||
$this->entityManager->saveEntity($massEmail);
|
||||
|
||||
$queueItemList = $this->entityManager
|
||||
->getRDBRepositoryByClass(EmailQueueItem::class)
|
||||
->where([
|
||||
'status' => EmailQueueItem::STATUS_PENDING,
|
||||
'massEmailId' => $massEmail->getId(),
|
||||
])
|
||||
->find();
|
||||
|
||||
foreach ($queueItemList as $queueItem) {
|
||||
$this->setItemFailed($queueItem);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EntityCollection<Attachment> $attachmentList
|
||||
*/
|
||||
private function sendQueueItem(
|
||||
EmailQueueItem $queueItem,
|
||||
MassEmail $massEmail,
|
||||
EmailTemplate $emailTemplate,
|
||||
EntityCollection $attachmentList,
|
||||
bool $isTest,
|
||||
?SmtpParams $smtpParams,
|
||||
SenderParams $senderParams,
|
||||
): void {
|
||||
|
||||
if ($this->isNotPending($queueItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setItemSending($queueItem);
|
||||
|
||||
$target = $this->entityManager->getEntityById($queueItem->getTargetType(), $queueItem->getTargetId());
|
||||
|
||||
$emailAddress = $target?->get(Field::EMAIL_ADDRESS);
|
||||
|
||||
if (
|
||||
!$target ||
|
||||
!$target->hasId() ||
|
||||
!$emailAddress
|
||||
) {
|
||||
$this->setItemFailed($queueItem);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$emailAddressRecord = $this->getEmailAddressRepository()->getByAddress($emailAddress);
|
||||
|
||||
if ($emailAddressRecord) {
|
||||
if ($emailAddressRecord->isInvalid()) {
|
||||
$this->setItemFailed($queueItem);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
$emailAddressRecord->isOptedOut() &&
|
||||
$massEmail->getCampaign()?->getType() !== Campaign::TYPE_INFORMATIONAL_EMAIL
|
||||
) {
|
||||
$this->setItemFailed($queueItem);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$email = $this->getPreparedEmail(
|
||||
queueItem: $queueItem,
|
||||
massEmail: $massEmail,
|
||||
emailTemplate: $emailTemplate,
|
||||
target: $target,
|
||||
trackingUrlList: $this->getTrackingUrls($massEmail->getCampaign()),
|
||||
);
|
||||
|
||||
if (!$email) {
|
||||
return;
|
||||
}
|
||||
|
||||
$senderParams = $this->prepareItemSenderParams($email, $senderParams, $massEmail);
|
||||
|
||||
$queueItem->incrementAttemptCount();
|
||||
|
||||
$sender = $this->emailSender->create();
|
||||
|
||||
if ($smtpParams) {
|
||||
$sender->withSmtpParams($smtpParams);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->prepareQueueItemMessage($queueItem, $sender, $senderParams);
|
||||
|
||||
$sender
|
||||
->withParams($senderParams)
|
||||
->withAttachments($attachmentList)
|
||||
->send($email);
|
||||
} catch (Exception $e) {
|
||||
$this->processException($queueItem, $e);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$emailObject = $emailTemplate;
|
||||
|
||||
if ($massEmail->storeSentEmails() && !$isTest) {
|
||||
$this->entityManager->saveEntity($email);
|
||||
|
||||
$emailObject = $email;
|
||||
}
|
||||
|
||||
$this->setItemSent($queueItem, $emailAddress);
|
||||
|
||||
if ($massEmail->getCampaign()) {
|
||||
$this->campaignService->logSent($massEmail->getCampaign()->getId(), $queueItem, $emailObject);
|
||||
}
|
||||
}
|
||||
|
||||
private function getSiteUrl(): string
|
||||
{
|
||||
return $this->config->get('massEmailSiteUrl') ??
|
||||
$this->applicationConfig->getSiteUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
* @throws NoSmtp
|
||||
* @return array{?SmtpParams, SenderParams}
|
||||
*/
|
||||
private function getSenderParams(MassEmail $massEmail): array
|
||||
{
|
||||
$smtpParams = null;
|
||||
$senderParams = SenderParams::create();
|
||||
|
||||
$inboundEmailId = $massEmail->getInboundEmailId();
|
||||
|
||||
if (!$inboundEmailId) {
|
||||
return [$smtpParams, $senderParams];
|
||||
}
|
||||
|
||||
$account = $this->accountFactory->create($inboundEmailId);
|
||||
|
||||
$smtpParams = $account->getSmtpParams();
|
||||
|
||||
if (
|
||||
!$account->isAvailableForSending() ||
|
||||
!$account->getEntity()->smtpIsForMassEmail() ||
|
||||
!$smtpParams
|
||||
) {
|
||||
throw new Error("Mass Email: Group email account $inboundEmailId can't be used for mass email.");
|
||||
}
|
||||
|
||||
if ($account->getEntity()->getReplyToAddress()) {
|
||||
$senderParams = $senderParams
|
||||
->withReplyToAddress($account->getEntity()->getReplyToAddress());
|
||||
}
|
||||
|
||||
return [$smtpParams, $senderParams];
|
||||
}
|
||||
|
||||
private function getCountLeft(MassEmail $massEmail): int
|
||||
{
|
||||
return $this->entityManager
|
||||
->getRDBRepositoryByClass(EmailQueueItem::class)
|
||||
->where([
|
||||
'status' => EmailQueueItem::STATUS_PENDING,
|
||||
'massEmailId' => $massEmail->getId(),
|
||||
'isTest' => false,
|
||||
])
|
||||
->count();
|
||||
}
|
||||
|
||||
private function processException(EmailQueueItem $queueItem, Exception $e): void
|
||||
{
|
||||
$maxAttemptCount = $this->config->get('massEmailMaxAttemptCount', self::MAX_ATTEMPT_COUNT);
|
||||
|
||||
$queueItem->getAttemptCount() >= $maxAttemptCount ?
|
||||
$queueItem->setStatus(EmailQueueItem::STATUS_FAILED) :
|
||||
$queueItem->setStatus(EmailQueueItem::STATUS_PENDING);
|
||||
|
||||
$this->entityManager->saveEntity($queueItem);
|
||||
|
||||
$this->log->error("Mass Email, send item: {$e->getCode()}, {$e->getMessage()}");
|
||||
}
|
||||
|
||||
private function getEmailAddressRepository(): EmailAddressRepository
|
||||
{
|
||||
/** @var EmailAddressRepository */
|
||||
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
private function isNotPending(EmailQueueItem $queueItem): bool
|
||||
{
|
||||
/** @var ?EmailQueueItem $queueItemFetched */
|
||||
$queueItemFetched = $this->entityManager->getEntityById(EmailQueueItem::ENTITY_TYPE, $queueItem->getId());
|
||||
|
||||
if (!$queueItemFetched) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $queueItemFetched->getStatus() !== EmailQueueItem::STATUS_PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<EmailQueueItem>
|
||||
*/
|
||||
private function getQueueItems(MassEmail $massEmail, bool $isTest, int $maxSize): Collection
|
||||
{
|
||||
return $this->entityManager
|
||||
->getRDBRepositoryByClass(EmailQueueItem::class)
|
||||
->sth()
|
||||
->where([
|
||||
'status' => EmailQueueItem::STATUS_PENDING,
|
||||
'massEmailId' => $massEmail->getId(),
|
||||
'isTest' => $isTest,
|
||||
])
|
||||
->limit(0, $maxSize)
|
||||
->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool Whether to skip.
|
||||
*/
|
||||
private function handleMaxSize(bool $isTest, int &$maxSize): bool
|
||||
{
|
||||
$hourMaxSize = $this->config->get('massEmailMaxPerHourCount', self::MAX_PER_HOUR_COUNT);
|
||||
$batchMaxSize = $this->config->get('massEmailMaxPerBatchCount');
|
||||
|
||||
if (!$isTest) {
|
||||
$threshold = DateTime::createNow()->addHours(-1);
|
||||
|
||||
$sentLastHourCount = $this->entityManager
|
||||
->getRDBRepositoryByClass(EmailQueueItem::class)
|
||||
->where([
|
||||
'status' => EmailQueueItem::STATUS_SENT,
|
||||
'sentAt>' => $threshold->toString(),
|
||||
])
|
||||
->count();
|
||||
|
||||
if ($sentLastHourCount >= $hourMaxSize) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$hourMaxSize = $hourMaxSize - $sentLastHourCount;
|
||||
}
|
||||
|
||||
$maxSize = $hourMaxSize;
|
||||
|
||||
if ($batchMaxSize) {
|
||||
$maxSize = min($batchMaxSize, $maxSize);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function setComplete(MassEmail $massEmail): void
|
||||
{
|
||||
$massEmail->setStatus(MassEmail::STATUS_COMPLETE);
|
||||
|
||||
$this->entityManager->saveEntity($massEmail);
|
||||
}
|
||||
|
||||
private function setItemSending(EmailQueueItem $queueItem): void
|
||||
{
|
||||
$queueItem->setStatus(EmailQueueItem::STATUS_SENDING);
|
||||
|
||||
$this->entityManager->saveEntity($queueItem);
|
||||
}
|
||||
|
||||
private function setItemFailed(EmailQueueItem $queueItem): void
|
||||
{
|
||||
$queueItem->setStatus(EmailQueueItem::STATUS_FAILED);
|
||||
|
||||
$this->entityManager->saveEntity($queueItem);
|
||||
}
|
||||
|
||||
private function setItemSent(EmailQueueItem $queueItem, string $emailAddress): void
|
||||
{
|
||||
$queueItem->setEmailAddress($emailAddress);
|
||||
$queueItem->setStatus(EmailQueueItem::STATUS_SENT);
|
||||
$queueItem->setSentAtNow();
|
||||
|
||||
$this->entityManager->saveEntity($queueItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<CampaignTrackingUrl>
|
||||
*/
|
||||
private function getTrackingUrls(?Campaign $campaign): iterable
|
||||
{
|
||||
if (!$campaign || $campaign->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var Collection<CampaignTrackingUrl> */
|
||||
return $this->entityManager
|
||||
->getRelation($campaign, 'trackingUrls')
|
||||
->find();
|
||||
}
|
||||
|
||||
private function prepareItemSenderParams(
|
||||
Email $email,
|
||||
SenderParams $senderParams,
|
||||
MassEmail $massEmail
|
||||
): SenderParams {
|
||||
|
||||
$campaign = $massEmail->getCampaign();
|
||||
|
||||
if ($email->get('replyToAddress')) { // @todo Revise.
|
||||
$senderParams = $senderParams->withReplyToAddress(null);
|
||||
}
|
||||
|
||||
if ($campaign) {
|
||||
$email->setLinkMultipleIdList(Field::TEAMS, $campaign->getLinkMultipleIdList(Field::TEAMS));
|
||||
}
|
||||
|
||||
$senderParams = $senderParams->withFromAddress(
|
||||
$massEmail->getFromAddress() ??
|
||||
$this->configDataProvider->getSystemOutboundAddress()
|
||||
);
|
||||
|
||||
if ($massEmail->getFromName()) {
|
||||
$senderParams = $senderParams->withFromName($massEmail->getFromName());
|
||||
}
|
||||
|
||||
if ($massEmail->getReplyToName()) {
|
||||
$senderParams = $senderParams->withReplyToName($massEmail->getReplyToName());
|
||||
}
|
||||
|
||||
return $senderParams;
|
||||
}
|
||||
|
||||
private function getOptOutUrl(EmailQueueItem $queueItem): string
|
||||
{
|
||||
return "{$this->getSiteUrl()}?entryPoint=unsubscribe&id={$queueItem->getId()}";
|
||||
}
|
||||
|
||||
private function getOptOutLink(string $optOutUrl): string
|
||||
{
|
||||
$label = $this->defaultLanguage->translateLabel('Unsubscribe', 'labels', Campaign::ENTITY_TYPE);
|
||||
|
||||
return "<a href=\"$optOutUrl\">$label</a>";
|
||||
}
|
||||
|
||||
private function getTrackUrl(mixed $trackingUrl, EmailQueueItem $queueItem): string
|
||||
{
|
||||
$siteUrl = $this->getSiteUrl();
|
||||
|
||||
$id1 = $trackingUrl->getId();
|
||||
$id2 = $queueItem->getId();
|
||||
|
||||
return "$siteUrl?entryPoint=campaignUrl&id=$id1&queueItemId=$id2";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<CampaignTrackingUrl> $trackingUrlList
|
||||
*/
|
||||
private function prepareBody(
|
||||
MassEmail $massEmail,
|
||||
EmailQueueItem $queueItem,
|
||||
Result $emailData,
|
||||
iterable $trackingUrlList,
|
||||
): string {
|
||||
|
||||
$body = $this->addBodyLinks(
|
||||
massEmail: $massEmail,
|
||||
queueItem: $queueItem,
|
||||
emailData: $emailData,
|
||||
body: $emailData->getBody(),
|
||||
trackingUrlList: $trackingUrlList,
|
||||
);
|
||||
|
||||
return $this->addBodyTracking(
|
||||
massEmail: $massEmail,
|
||||
queueItem: $queueItem,
|
||||
emailData: $emailData,
|
||||
body: $body,
|
||||
);
|
||||
}
|
||||
|
||||
private function toSkipAsInactive(MassEmail $massEmail, bool $isTest): bool
|
||||
{
|
||||
return !$isTest &&
|
||||
$massEmail->getCampaign() &&
|
||||
$massEmail->getCampaign()->getStatus() === Campaign::STATUS_INACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<CampaignTrackingUrl> $trackingUrlList
|
||||
*/
|
||||
private function addBodyLinks(
|
||||
MassEmail $massEmail,
|
||||
EmailQueueItem $queueItem,
|
||||
Result $emailData,
|
||||
string $body,
|
||||
iterable $trackingUrlList,
|
||||
): string {
|
||||
|
||||
$optOutUrl = $this->getOptOutUrl($queueItem);
|
||||
$optOutLink = $this->getOptOutLink($optOutUrl);
|
||||
|
||||
if (!$this->isInformational($massEmail)) {
|
||||
$body = str_replace('{optOutUrl}', $optOutUrl, $body);
|
||||
$body = str_replace('{optOutLink}', $optOutLink, $body);
|
||||
}
|
||||
|
||||
$body = str_replace('{queueItemId}', $queueItem->getId(), $body);
|
||||
|
||||
foreach ($trackingUrlList as $trackingUrl) {
|
||||
$url = $this->getTrackUrl($trackingUrl, $queueItem);
|
||||
|
||||
$body = str_replace($trackingUrl->getUrlToUse(), $url, $body);
|
||||
}
|
||||
|
||||
return $this->addMandatoryBodyOptOutLink($massEmail, $queueItem, $emailData, $body);
|
||||
}
|
||||
|
||||
private function addMandatoryBodyOptOutLink(
|
||||
MassEmail $massEmail,
|
||||
EmailQueueItem $queueItem,
|
||||
Result $emailData,
|
||||
string $body,
|
||||
): string {
|
||||
|
||||
if ($this->config->get('massEmailDisableMandatoryOptOutLink')) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
if ($this->isInformational($massEmail)) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
if (stripos($body, '?entryPoint=unsubscribe&id') !== false) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
$optOutUrl = $this->getOptOutUrl($queueItem);
|
||||
$optOutLink = $this->getOptOutLink($optOutUrl);
|
||||
|
||||
if ($emailData->isHtml()) {
|
||||
$body .= "<br><br>" . $optOutLink;
|
||||
} else {
|
||||
$body .= "\n\n" . $optOutUrl;
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function addBodyTracking(
|
||||
MassEmail $massEmail,
|
||||
EmailQueueItem $queueItem,
|
||||
Result $emailData,
|
||||
string $body
|
||||
): string {
|
||||
|
||||
if (!$massEmail->getCampaign()) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
if ($massEmail->getCampaign()->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
if (!$this->config->get('massEmailOpenTracking')) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
if (!$emailData->isHtml()) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
$alt = $this->defaultLanguage->translateLabel('Campaign', 'scopeNames');
|
||||
|
||||
$url = "{$this->getSiteUrl()}?entryPoint=campaignTrackOpened&id={$queueItem->getId()}";
|
||||
|
||||
/** @noinspection HtmlDeprecatedAttribute */
|
||||
$trackOpenedHtml = "<img alt=\"$alt\" width=\"1\" height=\"1\" border=\"0\" src=\"$url\">";
|
||||
|
||||
$body .= '<br>' . $trackOpenedHtml;
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function isInformational(MassEmail $massEmail): bool
|
||||
{
|
||||
return $massEmail->getCampaign()?->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL;
|
||||
}
|
||||
}
|
||||
171
application/Espo/Modules/Crm/Tools/MassEmail/Service.php
Normal file
171
application/Espo/Modules/Crm/Tools/MassEmail/Service.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Acl\Table;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Mail\Exceptions\NoSmtp;
|
||||
use Espo\Entities\InboundEmail;
|
||||
use Espo\Modules\Crm\Entities\MassEmail as MassEmailEntity;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
use stdClass;
|
||||
|
||||
class Service
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Acl $acl,
|
||||
private QueueCreator $queueCreator,
|
||||
private SendingProcessor $sendingProcessor
|
||||
) {}
|
||||
|
||||
/**
|
||||
* SMTP data for the front-end.
|
||||
*
|
||||
* @return stdClass[]
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function getSmtpAccountDataList(): array
|
||||
{
|
||||
if (
|
||||
!$this->acl->checkScope(MassEmailEntity::ENTITY_TYPE, Table::ACTION_CREATE) &&
|
||||
!$this->acl->checkScope(MassEmailEntity::ENTITY_TYPE, Table::ACTION_EDIT)
|
||||
) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$dataList = [];
|
||||
|
||||
/** @var Collection<InboundEmail> $inboundEmailList */
|
||||
$inboundEmailList = $this->entityManager
|
||||
->getRDBRepository(InboundEmail::ENTITY_TYPE)
|
||||
->where([
|
||||
'useSmtp' => true,
|
||||
'status' => InboundEmail::STATUS_ACTIVE,
|
||||
'smtpIsForMassEmail' => true,
|
||||
['emailAddress!=' => ''],
|
||||
['emailAddress!=' => null],
|
||||
])
|
||||
->find();
|
||||
|
||||
foreach ($inboundEmailList as $inboundEmail) {
|
||||
$item = (object) [];
|
||||
|
||||
$key = 'inboundEmail:' . $inboundEmail->getId();
|
||||
|
||||
$item->key = $key;
|
||||
$item->emailAddress = $inboundEmail->getEmailAddress();
|
||||
$item->fromName = $inboundEmail->getFromName();
|
||||
|
||||
$dataList[] = $item;
|
||||
}
|
||||
|
||||
return $dataList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test.
|
||||
*
|
||||
* @param stdClass[] $targetDataList
|
||||
* @throws BadRequest
|
||||
* @throws Error
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
* @throws NoSmtp
|
||||
*/
|
||||
public function processTest(string $id, array $targetDataList): void
|
||||
{
|
||||
$targetList = [];
|
||||
|
||||
if (count($targetDataList) === 0) {
|
||||
throw new BadRequest("Empty target list.");
|
||||
}
|
||||
|
||||
foreach ($targetDataList as $item) {
|
||||
if (empty($item->id) || empty($item->type)) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$targetId = $item->id;
|
||||
$targetType = $item->type;
|
||||
|
||||
$target = $this->entityManager->getEntityById($targetType, $targetId);
|
||||
|
||||
if (!$target) {
|
||||
throw new Error("Target not found.");
|
||||
}
|
||||
|
||||
if (!$this->acl->check($target, Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$targetList[] = $target;
|
||||
}
|
||||
|
||||
/** @var ?MassEmailEntity $massEmail */
|
||||
$massEmail = $this->entityManager->getEntityById(MassEmailEntity::ENTITY_TYPE, $id);
|
||||
|
||||
if (!$massEmail) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->check($massEmail, Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$this->createTestQueue($massEmail, $targetList);
|
||||
$this->processTestSending($massEmail);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Entity> $targetList
|
||||
* @throws Error
|
||||
*/
|
||||
private function createTestQueue(MassEmailEntity $massEmail, iterable $targetList): void
|
||||
{
|
||||
$this->queueCreator->create($massEmail, true, $targetList);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
* @throws NoSmtp
|
||||
*/
|
||||
private function processTestSending(MassEmailEntity $massEmail): void
|
||||
{
|
||||
$this->sendingProcessor->process($massEmail, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail;
|
||||
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\HookManager;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Hasher;
|
||||
use Espo\Entities\EmailAddress;
|
||||
use Espo\Modules\Crm\Entities\Campaign;
|
||||
use Espo\Modules\Crm\Entities\CampaignLogRecord;
|
||||
use Espo\Modules\Crm\Entities\EmailQueueItem;
|
||||
use Espo\Modules\Crm\Entities\MassEmail;
|
||||
use Espo\Modules\Crm\Entities\TargetList;
|
||||
use Espo\Modules\Crm\Tools\Campaign\LogService;
|
||||
use Espo\Modules\Crm\Tools\MassEmail\Util as MassEmailUtil;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Repositories\EmailAddress as EmailAddressRepository;
|
||||
|
||||
class UnsubscribeService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private HookManager $hookManager,
|
||||
private LogService $service,
|
||||
private MassEmailUtil $util,
|
||||
private Hasher $hasher,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function unsubscribe(string $queueItemId): void
|
||||
{
|
||||
[$queueItem, $campaign, $massEmail, $target] = $this->getRecords($queueItemId);
|
||||
|
||||
if ($massEmail->optOutEntirely()) {
|
||||
$emailAddress = $target->get('emailAddress');
|
||||
|
||||
if ($emailAddress) {
|
||||
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
|
||||
|
||||
if ($address) {
|
||||
$address->setOptedOut(true);
|
||||
$this->entityManager->saveEntity($address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$link = $this->util->getLinkByEntityType($target->getEntityType());
|
||||
|
||||
/** @var Collection<TargetList> $targetListList */
|
||||
$targetListList = $this->entityManager
|
||||
->getRDBRepository(MassEmail::ENTITY_TYPE)
|
||||
->getRelation($massEmail, 'targetLists')
|
||||
->find();
|
||||
|
||||
foreach ($targetListList as $targetList) {
|
||||
$relation = $this->entityManager
|
||||
->getRDBRepository(TargetList::ENTITY_TYPE)
|
||||
->getRelation($targetList, $link);
|
||||
|
||||
if ($relation->getColumn($target, 'optedOut')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relation->updateColumnsById($target->getId(), ['optedOut' => true]);
|
||||
|
||||
$hookData = [
|
||||
'link' => $link,
|
||||
'targetId' => $target->getId(),
|
||||
'targetType' => $target->getEntityType(),
|
||||
];
|
||||
|
||||
$this->hookManager->process(
|
||||
TargetList::ENTITY_TYPE,
|
||||
'afterOptOut',
|
||||
$targetList,
|
||||
[],
|
||||
$hookData
|
||||
);
|
||||
}
|
||||
|
||||
$this->hookManager->process($target->getEntityType(), 'afterOptOut', $target);
|
||||
|
||||
if ($campaign) {
|
||||
$this->service->logOptedOut($campaign->getId(), $queueItem, $target);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function subscribeAgain(string $queueItemId): void
|
||||
{
|
||||
[, $campaign, $massEmail, $target] = $this->getRecords($queueItemId);
|
||||
|
||||
if ($massEmail->optOutEntirely()) {
|
||||
$emailAddress = $target->get('emailAddress');
|
||||
|
||||
if ($emailAddress) {
|
||||
$ea = $this->getEmailAddressRepository()->getByAddress($emailAddress);
|
||||
|
||||
if ($ea) {
|
||||
$ea->setOptedOut(false);
|
||||
$this->entityManager->saveEntity($ea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$link = $this->util->getLinkByEntityType($target->getEntityType());
|
||||
|
||||
/** @var Collection<TargetList> $targetListList */
|
||||
$targetListList = $this->entityManager
|
||||
->getRDBRepository(MassEmail::ENTITY_TYPE)
|
||||
->getRelation($massEmail, 'targetLists')
|
||||
->find();
|
||||
|
||||
foreach ($targetListList as $targetList) {
|
||||
$relation = $this->entityManager
|
||||
->getRDBRepository(TargetList::ENTITY_TYPE)
|
||||
->getRelation($targetList, $link);
|
||||
|
||||
if (!$relation->getColumn($target, 'optedOut')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relation->updateColumnsById($target->getId(), ['optedOut' => false]);
|
||||
|
||||
$hookData = [
|
||||
'link' => $link,
|
||||
'targetId' => $target->getId(),
|
||||
'targetType' => $target->getEntityType(),
|
||||
];
|
||||
|
||||
$this->hookManager
|
||||
->process(TargetList::ENTITY_TYPE, 'afterCancelOptOut', $targetList, [], $hookData);
|
||||
}
|
||||
|
||||
$this->hookManager->process($target->getEntityType(), 'afterCancelOptOut', $target);
|
||||
|
||||
if ($campaign) {
|
||||
$logRecord = $this->entityManager
|
||||
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
|
||||
->where([
|
||||
'queueItemId' => $queueItemId,
|
||||
'action' => CampaignLogRecord::ACTION_OPTED_OUT,
|
||||
])
|
||||
->order(Field::CREATED_AT, true)
|
||||
->findOne();
|
||||
|
||||
if ($logRecord) {
|
||||
$this->entityManager->removeEntity($logRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function unsubscribeWithHash(string $emailAddress, string $hash): void
|
||||
{
|
||||
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
|
||||
|
||||
if ($address->isOptedOut()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$address->setOptedOut(true);
|
||||
$this->entityManager->saveEntity($address);
|
||||
|
||||
$entityList = $this->getEmailAddressRepository()->getEntityListByAddressId($address->getId());
|
||||
|
||||
foreach ($entityList as $entity) {
|
||||
$this->hookManager->process($entity->getEntityType(), 'afterOptOut', $entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function subscribeAgainWithHash(string $emailAddress, string $hash): void
|
||||
{
|
||||
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
|
||||
|
||||
if (!$address->isOptedOut()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityList = $this->getEmailAddressRepository()->getEntityListByAddressId($address->getId());
|
||||
|
||||
$address->setOptedOut(false);
|
||||
$this->entityManager->saveEntity($address);
|
||||
|
||||
foreach ($entityList as $entity) {
|
||||
$this->hookManager->process($entity->getEntityType(), 'afterCancelOptOut', $entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function isSubscribed(string $queueItemId): bool
|
||||
{
|
||||
[,, $massEmail, $target] = $this->getRecords($queueItemId);
|
||||
|
||||
if ($massEmail->optOutEntirely()) {
|
||||
$emailAddress = $target->get(Field::EMAIL_ADDRESS);
|
||||
|
||||
if ($emailAddress) {
|
||||
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
|
||||
|
||||
if ($address && !$address->isOptedOut()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$link = $this->util->getLinkByEntityType($target->getEntityType());
|
||||
|
||||
foreach ($massEmail->getTargetLists() as $targetList) {
|
||||
$relation = $this->entityManager->getRelation($targetList, $link);
|
||||
|
||||
if (!$relation->getColumn($target, 'optedOut')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function isSubscribedWithHash(string $emailAddress, string $hash): bool
|
||||
{
|
||||
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
|
||||
|
||||
return !$address->isOptedOut();
|
||||
}
|
||||
|
||||
private function getEmailAddressRepository(): EmailAddressRepository
|
||||
{
|
||||
/** @var EmailAddressRepository */
|
||||
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{EmailQueueItem, ?Campaign, MassEmail, Entity}
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getRecords(string $queueItemId): array
|
||||
{
|
||||
$queueItem = $this->entityManager->getRDBRepositoryByClass(EmailQueueItem::class)->getById($queueItemId);
|
||||
|
||||
if (!$queueItem) {
|
||||
throw new NotFound("No item.");
|
||||
}
|
||||
|
||||
$massEmail = $queueItem->getMassEmail();
|
||||
|
||||
if (!$massEmail) {
|
||||
throw new NotFound("Mass Email not found or not set.");
|
||||
}
|
||||
|
||||
$campaign = $massEmail->getCampaign();
|
||||
$targetType = $queueItem->getTargetType();
|
||||
$targetId = $queueItem->getTargetId();
|
||||
|
||||
if ($campaign && $campaign->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL) {
|
||||
throw new NotFound("Campaign is informational.");
|
||||
}
|
||||
|
||||
$target = $this->entityManager->getEntityById($targetType, $targetId);
|
||||
|
||||
if (!$target) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
return [$queueItem, $campaign, $massEmail, $target];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getEmailAddressWithHash(string $emailAddress, string $hash): EmailAddress
|
||||
{
|
||||
$hash2 = $this->hasher->hash($emailAddress);
|
||||
|
||||
if ($hash2 !== $hash) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
|
||||
|
||||
if (!$address) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
return $address;
|
||||
}
|
||||
}
|
||||
64
application/Espo/Modules/Crm/Tools/MassEmail/Util.php
Normal file
64
application/Espo/Modules/Crm/Tools/MassEmail/Util.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?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\Modules\Crm\Tools\MassEmail;
|
||||
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Defs;
|
||||
use Espo\Modules\Crm\Entities\TargetList;
|
||||
use RuntimeException;
|
||||
|
||||
class Util
|
||||
{
|
||||
/** @var string[] */
|
||||
private array $targetLinkList;
|
||||
|
||||
public function __construct(
|
||||
private Defs $ormDefs,
|
||||
private Metadata $metadata
|
||||
) {
|
||||
$this->targetLinkList = $this->metadata->get(['scopes', 'TargetList', 'targetLinkList']) ?? [];
|
||||
}
|
||||
|
||||
public function getLinkByEntityType(string $entityType): string
|
||||
{
|
||||
foreach ($this->targetLinkList as $link) {
|
||||
$itemEntityType = $this->ormDefs
|
||||
->getEntity(TargetList::ENTITY_TYPE)
|
||||
->getRelation($link)
|
||||
->getForeignEntityType();
|
||||
|
||||
if ($itemEntityType === $entityType) {
|
||||
return $link;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException("No link for $entityType.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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\Modules\Crm\Tools\Meeting\Invitation;
|
||||
|
||||
readonly class Invitee
|
||||
{
|
||||
public function __construct(
|
||||
private string $entityType,
|
||||
private string $id,
|
||||
private ?string $emailAddress = null,
|
||||
) {}
|
||||
|
||||
public function getEntityType(): string
|
||||
{
|
||||
return $this->entityType;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEmailAddress(): ?string
|
||||
{
|
||||
return $this->emailAddress;
|
||||
}
|
||||
}
|
||||
223
application/Espo/Modules/Crm/Tools/Meeting/Invitation/Sender.php
Normal file
223
application/Espo/Modules/Crm/Tools/Meeting/Invitation/Sender.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?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\Modules\Crm\Tools\Meeting\Invitation;
|
||||
|
||||
use Espo\Core\Binding\BindingContainerBuilder;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Mail\Exceptions\SendingError;
|
||||
use Espo\Core\Mail\SmtpParams;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Business\Event\Invitations;
|
||||
use Espo\Modules\Crm\Entities\Call;
|
||||
use Espo\Modules\Crm\Entities\Meeting;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\Email\SendService;
|
||||
|
||||
/**
|
||||
* @since 9.0.0
|
||||
*/
|
||||
class Sender
|
||||
{
|
||||
private const TYPE_INVITATION = 'invitation';
|
||||
private const TYPE_CANCELLATION = 'cancellation';
|
||||
|
||||
public function __construct(
|
||||
private SendService $sendService,
|
||||
private User $user,
|
||||
private InjectableFactory $injectableFactory,
|
||||
private EntityManager $entityManager,
|
||||
private Config $config,
|
||||
private Metadata $metadata,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param Meeting|Call $entity
|
||||
* @param ?Invitee[] $targets
|
||||
* @return Entity[] Entities an invitation was sent to.
|
||||
* @throws SendingError
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function sendInvitation(Meeting|Call $entity, ?array $targets = null): array
|
||||
{
|
||||
return $this->sendInternal($entity, self::TYPE_INVITATION, $targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Meeting|Call $entity
|
||||
* @param ?Invitee[] $targets
|
||||
* @return Entity[] Entities an invitation was sent to.
|
||||
* @throws SendingError
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function sendCancellation(Meeting|Call $entity, ?array $targets = null): array
|
||||
{
|
||||
return $this->sendInternal($entity, self::TYPE_CANCELLATION, $targets);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param ?Invitee[] $targets
|
||||
* @return Entity[]
|
||||
* @throws SendingError
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function sendInternal(Meeting|Call $entity, string $type, ?array $targets): array
|
||||
{
|
||||
$this->checkStatus($entity, $type);
|
||||
|
||||
$linkList = [
|
||||
Meeting::LINK_USERS,
|
||||
Meeting::LINK_CONTACTS,
|
||||
Meeting::LINK_LEADS,
|
||||
];
|
||||
|
||||
$sender = $this->getSender();
|
||||
|
||||
$sentAddressList = [];
|
||||
$resultEntityList = [];
|
||||
|
||||
foreach ($linkList as $link) {
|
||||
$builder = $this->entityManager->getRelation($entity, $link);
|
||||
|
||||
if ($targets === null && $type === self::TYPE_INVITATION) {
|
||||
$builder->where(['@relation.status=' => Meeting::ATTENDEE_STATUS_NONE]);
|
||||
}
|
||||
|
||||
$collection = $builder->find();
|
||||
|
||||
foreach ($collection as $attendee) {
|
||||
$emailAddress = $attendee->get(Field::EMAIL_ADDRESS);
|
||||
|
||||
if ($targets) {
|
||||
$target = self::findTarget($attendee, $targets);
|
||||
|
||||
if (!$target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($target->getEmailAddress()) {
|
||||
$emailAddress = $target->getEmailAddress();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$emailAddress || in_array($emailAddress, $sentAddressList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === self::TYPE_INVITATION) {
|
||||
$sender->sendInvitation($entity, $attendee, $link, $emailAddress);
|
||||
}
|
||||
|
||||
if ($type === self::TYPE_CANCELLATION) {
|
||||
$sender->sendCancellation($entity, $attendee, $link, $emailAddress);
|
||||
}
|
||||
|
||||
$sentAddressList[] = $emailAddress;
|
||||
$resultEntityList[] = $attendee;
|
||||
|
||||
$this->entityManager
|
||||
->getRelation($entity, $link)
|
||||
->updateColumns($attendee, ['status' => Meeting::ATTENDEE_STATUS_NONE]);
|
||||
}
|
||||
}
|
||||
|
||||
return $resultEntityList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Invitee[] $targets
|
||||
*/
|
||||
private static function findTarget(Entity $entity, array $targets): ?Invitee
|
||||
{
|
||||
foreach ($targets as $target) {
|
||||
if (
|
||||
$entity->getEntityType() === $target->getEntityType() &&
|
||||
$entity->getId() === $target->getId()
|
||||
) {
|
||||
return $target;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getSender(): Invitations
|
||||
{
|
||||
$smtpParams = !$this->config->get('eventInvitationForceSystemSmtp') ?
|
||||
$this->sendService->getUserSmtpParams($this->user->getId()) :
|
||||
null;
|
||||
|
||||
$builder = BindingContainerBuilder::create();
|
||||
|
||||
if ($smtpParams) {
|
||||
$builder->bindInstance(SmtpParams::class, $smtpParams);
|
||||
}
|
||||
|
||||
return $this->injectableFactory->createWithBinding(Invitations::class, $builder->build());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkStatus(Meeting|Call $entity, string $type): void
|
||||
{
|
||||
$entityType = $entity->getEntityType();
|
||||
|
||||
if ($type === self::TYPE_CANCELLATION) {
|
||||
if (!in_array($entity->getStatus(), $this->getCanceledStatusList($entityType))) {
|
||||
throw new Forbidden("Can't send invitation for not canceled event.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$notActualStatusList = [
|
||||
...($this->metadata->get("scopes.$entityType.completedStatusList") ?? []),
|
||||
...$this->getCanceledStatusList($entityType),
|
||||
];
|
||||
|
||||
if (in_array($entity->getStatus(), $notActualStatusList)) {
|
||||
throw new Forbidden("Can't send invitation for not actual event.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getCanceledStatusList(string $entityType): array
|
||||
{
|
||||
return $this->metadata->get("scopes.$entityType.canceledStatusList") ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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\Modules\Crm\Tools\Meeting\Invitation;
|
||||
|
||||
use Espo\Core\Binding\BindingContainerBuilder;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Entities\User;
|
||||
|
||||
/**
|
||||
* @since 9.0.0
|
||||
*/
|
||||
class SenderFactory
|
||||
{
|
||||
public function __construct(
|
||||
private InjectableFactory $injectableFactory,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a sender for a user. The SMTP of the user will be used (if not disabled in the config).
|
||||
*/
|
||||
public function createForUser(User $user): Sender
|
||||
{
|
||||
return $this->injectableFactory->createWithBinding(
|
||||
Sender::class,
|
||||
BindingContainerBuilder::create()
|
||||
->bindInstance(User::class, $user)
|
||||
->build()
|
||||
);
|
||||
}
|
||||
}
|
||||
122
application/Espo/Modules/Crm/Tools/Meeting/InvitationService.php
Normal file
122
application/Espo/Modules/Crm/Tools/Meeting/InvitationService.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?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\Modules\Crm\Tools\Meeting;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Mail\Exceptions\SendingError;
|
||||
use Espo\Core\Record\ServiceContainer as RecordServiceContainer;
|
||||
use Espo\Modules\Crm\Entities\Call;
|
||||
use Espo\Modules\Crm\Entities\Meeting;
|
||||
use Espo\Modules\Crm\Tools\Meeting\Invitation\Sender;
|
||||
use Espo\Modules\Crm\Tools\Meeting\Invitation\Invitee;
|
||||
use Espo\ORM\Entity;
|
||||
|
||||
class InvitationService
|
||||
{
|
||||
private const TYPE_INVITATION = 'invitation';
|
||||
private const TYPE_CANCELLATION = 'cancellation';
|
||||
|
||||
public function __construct(
|
||||
private RecordServiceContainer $recordServiceContainer,
|
||||
private Acl $acl,
|
||||
private Sender $invitationSender,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send invitation emails for a meeting (or call). Checks access. Uses user's SMTP if available.
|
||||
*
|
||||
* @param ?Invitee[] $targets
|
||||
* @return Entity[] Entities an invitation was sent to.
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
* @throws SendingError
|
||||
*/
|
||||
public function send(string $entityType, string $id, ?array $targets = null): array
|
||||
{
|
||||
return $this->sendInternal($entityType, $id, $targets, self::TYPE_INVITATION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send cancellation emails for a meeting (or call). Checks access. Uses user's SMTP if available.
|
||||
*
|
||||
* @param ?Invitee[] $targets
|
||||
* @return Entity[] Entities a cancellation was sent to.
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
* @throws SendingError
|
||||
*/
|
||||
public function sendCancellation(string $entityType, string $id, ?array $targets = null): array
|
||||
{
|
||||
return $this->sendInternal($entityType, $id, $targets, self::TYPE_CANCELLATION);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?Invitee[] $targets
|
||||
* @return Entity[]
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
* @throws SendingError
|
||||
*/
|
||||
private function sendInternal(
|
||||
string $entityType,
|
||||
string $id,
|
||||
?array $targets,
|
||||
string $type,
|
||||
): array {
|
||||
|
||||
$entity = $this->recordServiceContainer
|
||||
->get($entityType)
|
||||
->getEntity($id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($entity)) {
|
||||
throw new Forbidden("No edit access.");
|
||||
}
|
||||
|
||||
if (!$entity instanceof Meeting && !$entity instanceof Call) {
|
||||
throw new Error("Not supported entity type.");
|
||||
}
|
||||
|
||||
if ($type === self::TYPE_CANCELLATION) {
|
||||
return $this->invitationSender->sendCancellation($entity, $targets);
|
||||
}
|
||||
|
||||
return $this->invitationSender->sendInvitation($entity, $targets);
|
||||
}
|
||||
}
|
||||
268
application/Espo/Modules/Crm/Tools/Meeting/Service.php
Normal file
268
application/Espo/Modules/Crm/Tools/Meeting/Service.php
Normal file
@@ -0,0 +1,268 @@
|
||||
<?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\Modules\Crm\Tools\Meeting;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\HookManager;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\Record\Collection as RecordCollection;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Entities\Call;
|
||||
use Espo\Modules\Crm\Entities\Meeting;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use LogicException;
|
||||
|
||||
class Service
|
||||
{
|
||||
private const NOTE_TYPE_EVENT_CONFIRMATION = 'EventConfirmation';
|
||||
|
||||
public function __construct(
|
||||
private User $user,
|
||||
private EntityManager $entityManager,
|
||||
private HookManager $hookManager,
|
||||
private Acl $acl,
|
||||
private Metadata $metadata
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Set an acceptance for a current user.
|
||||
*
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function setAcceptance(string $entityType, string $id, string $status): void
|
||||
{
|
||||
/** @var string[] $statusList */
|
||||
$statusList = $this->entityManager
|
||||
->getDefs()
|
||||
->getEntity($entityType)
|
||||
->getField('acceptanceStatus')
|
||||
->getParam('options') ?? [];
|
||||
|
||||
if (!in_array($status, $statusList) || $status === Meeting::ATTENDEE_STATUS_NONE) {
|
||||
throw new BadRequest("Acceptance status not allowed.");
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById($entityType, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
if (!$entity->hasLinkMultipleId('users', $this->user->getId())) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$currentStatus = $this->entityManager
|
||||
->getRDBRepository($entityType)
|
||||
->getRelation($entity, 'users')
|
||||
->getColumn($this->user, 'status');
|
||||
|
||||
if ($currentStatus === $status) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->entityManager
|
||||
->getRDBRepository($entityType)
|
||||
->getRelation($entity, 'users')
|
||||
->updateColumnsById($this->user->getId(), ['status' => $status]);
|
||||
|
||||
if ($this->metadata->get(['scopes', $entityType, 'stream'])) {
|
||||
$this->createEventConfirmationNote($entity, $status);
|
||||
}
|
||||
|
||||
$actionData = [
|
||||
'eventName' => $entity->get(Field::NAME),
|
||||
'eventType' => $entity->getEntityType(),
|
||||
'eventId' => $entity->getId(),
|
||||
'dateStart' => $entity->get('dateStart'),
|
||||
'status' => $status,
|
||||
'link' => 'users',
|
||||
'inviteeType' => User::ENTITY_TYPE,
|
||||
'inviteeId' => $this->user->getId(),
|
||||
];
|
||||
|
||||
$this->hookManager->process($entityType, 'afterConfirmation', $entity, [], $actionData);
|
||||
}
|
||||
|
||||
private function createEventConfirmationNote(CoreEntity $entity, string $status): void
|
||||
{
|
||||
$options = ['createdById' => $this->user->getId()];
|
||||
|
||||
$style = $this->metadata
|
||||
->get(['entityDefs', $entity->getEntityType(), 'fields', 'acceptanceStatus', 'style', $status]);
|
||||
|
||||
$this->entityManager->createEntity(Note::ENTITY_TYPE, [
|
||||
'type' => self::NOTE_TYPE_EVENT_CONFIRMATION,
|
||||
'parentId' => $entity->getId(),
|
||||
'parentType' => $entity->getEntityType(),
|
||||
'relatedId' => $this->user->getId(),
|
||||
'relatedType' => $this->user->getEntityType(),
|
||||
'data' => [
|
||||
'status' => $status,
|
||||
'style' => $style,
|
||||
],
|
||||
], $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ids
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function massSetHeld(string $entityType, array $ids): void
|
||||
{
|
||||
if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_EDIT)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField($entityType, 'status', Acl\Table::ACTION_EDIT)) {
|
||||
throw new Forbidden("No edit access to 'status' field.");
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$entity = $this->entityManager->getEntityById($entityType, $id);
|
||||
|
||||
if (!$entity || !$this->acl->checkEntityEdit($entity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entity->set('status', Meeting::STATUS_HELD);
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ids
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function massSetNotHeld(string $entityType, array $ids): void
|
||||
{
|
||||
if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_EDIT)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField($entityType, 'status', Acl\Table::ACTION_EDIT)) {
|
||||
throw new Forbidden("No edit access to 'status' field.");
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$entity = $this->entityManager->getEntityById($entityType, $id);
|
||||
|
||||
if (!$entity || !$this->acl->checkEntityEdit($entity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entity->set('status', Meeting::STATUS_NOT_HELD);
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all attendees.
|
||||
*
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
* @return RecordCollection<Entity>
|
||||
*/
|
||||
public function getAttendees(string $entityType, string $id): RecordCollection
|
||||
{
|
||||
$entity = $this->entityManager->getEntityById($entityType, $id);
|
||||
|
||||
if (!in_array($entityType, [Meeting::ENTITY_TYPE, Call::ENTITY_TYPE])) {
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityRead($entity)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$linkList = [
|
||||
'users',
|
||||
'contacts',
|
||||
'leads',
|
||||
];
|
||||
|
||||
$linkList = array_filter($linkList, function ($item) use ($entityType) {
|
||||
return $this->acl->checkField($item, $entityType);
|
||||
});
|
||||
|
||||
$linkList = array_values($linkList);
|
||||
|
||||
$list = [];
|
||||
|
||||
foreach ($linkList as $link) {
|
||||
$itemCollection = $this->entityManager
|
||||
->getRDBRepository($entityType)
|
||||
->getRelation($entity, $link)
|
||||
->select([Attribute::ID, Field::NAME, 'acceptanceStatus', 'emailAddress'])
|
||||
->order('name')
|
||||
->find();
|
||||
|
||||
$list = array_merge($list, [...$itemCollection]);
|
||||
}
|
||||
|
||||
/** @var Collection<Entity> $collection */
|
||||
$collection = $this->entityManager->getCollectionFactory()->create(null, $list);
|
||||
|
||||
foreach ($collection as $e) {
|
||||
if ($this->acl->checkEntityRead($e) && $this->acl->checkField($entityType, 'emailAddress')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$e->get('emailAddress')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$e->set('emailAddress', 'dummy@dummy.dummy');
|
||||
}
|
||||
|
||||
return RecordCollection::create($collection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?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\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
|
||||
class ByLeadSource
|
||||
{
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private Config $config,
|
||||
private Metadata $metadata,
|
||||
private EntityManager $entityManager,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private Util $util
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function run(DateRange $range): stdClass
|
||||
{
|
||||
$range = $range->withFiscalYearShift(
|
||||
$this->config->get('fiscalYearShift') ?? 0
|
||||
);
|
||||
|
||||
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
|
||||
throw new Forbidden("No access to 'amount' field.");
|
||||
}
|
||||
|
||||
[$from, $to] = $range->getRange();
|
||||
|
||||
$options = $this->metadata->get('entityDefs.Lead.fields.source.options', []);
|
||||
|
||||
try {
|
||||
$queryBuilder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Opportunity::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder();
|
||||
} catch (BadRequest|Forbidden $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
$whereClause = [
|
||||
['stage!=' => $this->util->getLostStageList()],
|
||||
['leadSource!=' => ''],
|
||||
['leadSource!=' => null],
|
||||
];
|
||||
|
||||
if ($from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($from && !$to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
$queryBuilder
|
||||
->select([
|
||||
'leadSource',
|
||||
['SUM:amountWeightedConverted', 'amount'],
|
||||
])
|
||||
->order(
|
||||
Order::createByPositionInList(
|
||||
Expression::column('leadSource'),
|
||||
$options
|
||||
)
|
||||
)
|
||||
->group('leadSource')
|
||||
->where($whereClause);
|
||||
|
||||
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
|
||||
|
||||
$sth = $this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($queryBuilder->build());
|
||||
|
||||
$rowList = $sth->fetchAll() ?: [];
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rowList as $row) {
|
||||
$leadSource = $row['leadSource'];
|
||||
|
||||
$result[$leadSource] = floatval($row['amount']);
|
||||
}
|
||||
|
||||
return (object) $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?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\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use stdClass;
|
||||
|
||||
class ByStage
|
||||
{
|
||||
private Acl $acl;
|
||||
private Config $config;
|
||||
private Metadata $metadata;
|
||||
private EntityManager $entityManager;
|
||||
private SelectBuilderFactory $selectBuilderFactory;
|
||||
private Util $util;
|
||||
|
||||
public function __construct(
|
||||
Acl $acl,
|
||||
Config $config,
|
||||
Metadata $metadata,
|
||||
EntityManager $entityManager,
|
||||
SelectBuilderFactory $selectBuilderFactory,
|
||||
Util $util
|
||||
) {
|
||||
$this->acl = $acl;
|
||||
$this->config = $config;
|
||||
$this->metadata = $metadata;
|
||||
$this->entityManager = $entityManager;
|
||||
$this->selectBuilderFactory = $selectBuilderFactory;
|
||||
$this->util = $util;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function run(DateRange $range): stdClass
|
||||
{
|
||||
$range = $range->withFiscalYearShift(
|
||||
$this->config->get('fiscalYearShift') ?? 0
|
||||
);
|
||||
|
||||
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
|
||||
throw new Forbidden("No access to 'amount' field.");
|
||||
}
|
||||
|
||||
[$from, $to] = $range->getRange();
|
||||
|
||||
$options = $this->metadata->get('entityDefs.Opportunity.fields.stage.options') ?? [];
|
||||
|
||||
$queryBuilder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Opportunity::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder();
|
||||
|
||||
$whereClause = [
|
||||
['stage!=' => $this->util->getLostStageList()],
|
||||
['stage!=' => $this->util->getWonStageList()],
|
||||
];
|
||||
|
||||
if ($from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($from && !$to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
$queryBuilder
|
||||
->select([
|
||||
'stage',
|
||||
['SUM:amountConverted', 'amount'],
|
||||
])
|
||||
->order(
|
||||
Order::createByPositionInList(
|
||||
Expression::column('stage'),
|
||||
$options
|
||||
)
|
||||
)
|
||||
->group('stage')
|
||||
->where($whereClause);
|
||||
|
||||
$stageIgnoreList = array_merge(
|
||||
$this->util->getLostStageList(),
|
||||
$this->util->getWonStageList()
|
||||
);
|
||||
|
||||
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
|
||||
|
||||
$sth = $this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($queryBuilder->build());
|
||||
|
||||
$rowList = $sth->fetchAll() ?: [];
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rowList as $row) {
|
||||
$stage = $row['stage'];
|
||||
|
||||
if (in_array($stage, $stageIgnoreList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$stage] = floatval($row['amount']);
|
||||
}
|
||||
|
||||
foreach ($options as $stage) {
|
||||
if (in_array($stage, $stageIgnoreList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists($stage, $result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$stage] = 0.0;
|
||||
}
|
||||
|
||||
return (object) $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?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\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use DateInterval;
|
||||
use Espo\Core\Field\Date;
|
||||
use InvalidArgumentException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* Immutable.
|
||||
*/
|
||||
class DateRange
|
||||
{
|
||||
public const TYPE_BETWEEN = 'between';
|
||||
public const TYPE_EVER = 'ever';
|
||||
public const TYPE_CURRENT_YEAR = 'currentYear';
|
||||
public const TYPE_CURRENT_QUARTER = 'currentQuarter';
|
||||
public const TYPE_CURRENT_MONTH = 'currentMonth';
|
||||
public const TYPE_CURRENT_FISCAL_YEAR = 'currentFiscalYear';
|
||||
public const TYPE_CURRENT_FISCAL_QUARTER = 'currentFiscalQuarter';
|
||||
|
||||
private string $type;
|
||||
private ?Date $from;
|
||||
private ?Date $to;
|
||||
private int $fiscalYearShift;
|
||||
|
||||
public function __construct(
|
||||
string $type,
|
||||
?Date $from = null,
|
||||
?Date $to = null,
|
||||
int $fiscalYearShift = 0
|
||||
) {
|
||||
if ($type === self::TYPE_BETWEEN && (!$from || !$to)) {
|
||||
throw new InvalidArgumentException("Missing range dates.");
|
||||
}
|
||||
|
||||
$this->type = $type;
|
||||
$this->from = $from;
|
||||
$this->to = $to;
|
||||
$this->fiscalYearShift = $fiscalYearShift;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getFrom(): ?Date
|
||||
{
|
||||
return $this->from;
|
||||
}
|
||||
|
||||
public function getTo(): ?Date
|
||||
{
|
||||
return $this->to;
|
||||
}
|
||||
|
||||
public function withFiscalYearShift(int $fiscalYearShift): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->fiscalYearShift = $fiscalYearShift;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{?Date, ?Date}
|
||||
*/
|
||||
public function getRange(): array
|
||||
{
|
||||
if ($this->type === self::TYPE_EVER) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
if ($this->type === self::TYPE_BETWEEN) {
|
||||
return [$this->from, $this->to];
|
||||
}
|
||||
|
||||
$fiscalYearShift = $this->fiscalYearShift;
|
||||
|
||||
switch ($this->type) {
|
||||
case self::TYPE_CURRENT_YEAR:
|
||||
$dt = Date::createToday()
|
||||
->modify('first day of January this year');
|
||||
|
||||
return [
|
||||
$dt,
|
||||
$dt->addYears(1)
|
||||
];
|
||||
|
||||
case self::TYPE_CURRENT_QUARTER:
|
||||
$dt = Date::createToday();
|
||||
|
||||
$quarter = (int) ceil($dt->getMonth() / 3);
|
||||
|
||||
$dt = $dt
|
||||
->modify('first day of January this year')
|
||||
->addMonths(($quarter - 1) * 3);
|
||||
|
||||
return [
|
||||
$dt,
|
||||
$dt->addMonths(3),
|
||||
];
|
||||
|
||||
case self::TYPE_CURRENT_MONTH:
|
||||
$dt = Date::createToday()
|
||||
->modify('first day of this month');
|
||||
|
||||
return [
|
||||
$dt,
|
||||
$dt->addMonths(1),
|
||||
];
|
||||
|
||||
case self::TYPE_CURRENT_FISCAL_YEAR:
|
||||
$dt = Date::createToday()
|
||||
->modify('first day of January this year')
|
||||
->modify('+' . $fiscalYearShift . ' months');
|
||||
|
||||
if (Date::createToday()->getMonth() < $fiscalYearShift + 1) {
|
||||
$dt = $dt->addYears(-1);
|
||||
}
|
||||
|
||||
return [
|
||||
$dt,
|
||||
$dt->addYears(1)
|
||||
];
|
||||
|
||||
case self::TYPE_CURRENT_FISCAL_QUARTER:
|
||||
$dt = Date::createToday()
|
||||
->modify('first day of January this year')
|
||||
->addMonths($fiscalYearShift);
|
||||
|
||||
$month = Date::createToday()->getMonth();
|
||||
|
||||
$quarterShift = (int) floor(($month - $fiscalYearShift - 1) / 3);
|
||||
|
||||
if ($quarterShift) {
|
||||
$dt = $dt->addMonths($quarterShift * 3);
|
||||
}
|
||||
|
||||
return [
|
||||
$dt,
|
||||
$dt->add(new DateInterval('P3M'))
|
||||
];
|
||||
}
|
||||
|
||||
throw new UnexpectedValueException("Not supported range type");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?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\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use DateTime;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Exception;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use stdClass;
|
||||
|
||||
class SalesByMonth
|
||||
{
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private Config $config,
|
||||
private EntityManager $entityManager,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private Util $util
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function run(DateRange $range): stdClass
|
||||
{
|
||||
$range = $range->withFiscalYearShift(
|
||||
$this->config->get('fiscalYearShift') ?? 0
|
||||
);
|
||||
|
||||
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
|
||||
throw new Forbidden("No access to 'amount' field.");
|
||||
}
|
||||
|
||||
[$from, $to] = $range->getRange();
|
||||
|
||||
if (!$from || !$to) {
|
||||
throw new InvalidArgumentException();
|
||||
}
|
||||
|
||||
$queryBuilder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Opportunity::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder();
|
||||
|
||||
$whereClause = [
|
||||
'stage' => $this->util->getWonStageList(),
|
||||
];
|
||||
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
|
||||
$queryBuilder
|
||||
->select([
|
||||
['MONTH:closeDate', 'month'],
|
||||
['SUM:amountConverted', 'amount'],
|
||||
])
|
||||
->order('MONTH:closeDate')
|
||||
->group('MONTH:closeDate')
|
||||
->where($whereClause);
|
||||
|
||||
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
|
||||
|
||||
$sth = $this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($queryBuilder->build());
|
||||
|
||||
$result = [];
|
||||
|
||||
$rowList = $sth->fetchAll() ?: [];
|
||||
|
||||
foreach ($rowList as $row) {
|
||||
$month = $row['month'];
|
||||
|
||||
$result[$month] = floatval($row['amount']);
|
||||
}
|
||||
|
||||
$dt = $from;
|
||||
$dtTo = $to;
|
||||
|
||||
if ($dtTo->getDay() > 1) {
|
||||
$dtTo = $dtTo
|
||||
->addDays(1 - $dtTo->getDay()) // First day of month.
|
||||
->addMonths(1);
|
||||
} else {
|
||||
$dtTo = $dtTo->addDays(1 - $dtTo->getDay());
|
||||
}
|
||||
|
||||
while ($dt->toTimestamp() < $dtTo->toTimestamp()) {
|
||||
$month = $dt->toDateTime()->format('Y-m');
|
||||
|
||||
if (!array_key_exists($month, $result)) {
|
||||
$result[$month] = 0;
|
||||
}
|
||||
|
||||
$dt = $dt->addMonths(1);
|
||||
}
|
||||
|
||||
$keyList = array_keys($result);
|
||||
|
||||
sort($keyList);
|
||||
|
||||
$today = new DateTime();
|
||||
$endPosition = count($keyList);
|
||||
|
||||
for ($i = count($keyList) - 1; $i >= 0; $i--) {
|
||||
$key = $keyList[$i];
|
||||
|
||||
try {
|
||||
$dt = new DateTime($key . '-01');
|
||||
} catch (Exception $e) {
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
if ($dt->getTimestamp() < $today->getTimestamp()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (empty($result[$key])) {
|
||||
$endPosition = $i;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$keyListSliced = array_slice($keyList, 0, $endPosition);
|
||||
|
||||
return (object) [
|
||||
'keyList' => $keyListSliced,
|
||||
'dataMap' => $result,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?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\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use stdClass;
|
||||
|
||||
class SalesPipeline
|
||||
{
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private Config $config,
|
||||
private Metadata $metadata,
|
||||
private EntityManager $entityManager,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private Util $util
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function run(DateRange $range, bool $useLastStage = false, ?string $teamId = null): stdClass
|
||||
{
|
||||
$range = $range->withFiscalYearShift(
|
||||
$this->config->get('fiscalYearShift') ?? 0
|
||||
);
|
||||
|
||||
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
|
||||
throw new Forbidden("No access to 'amount' field.");
|
||||
}
|
||||
|
||||
[$from, $to] = $range->getRange();
|
||||
|
||||
$lostStageList = $this->util->getLostStageList();
|
||||
|
||||
$options = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
|
||||
|
||||
$queryBuilder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Opportunity::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder();
|
||||
|
||||
$stageField = 'stage';
|
||||
|
||||
if ($useLastStage) {
|
||||
$stageField = 'lastStage';
|
||||
}
|
||||
|
||||
$whereClause = [
|
||||
[$stageField . '!=' => $lostStageList],
|
||||
[$stageField . '!=' => null],
|
||||
];
|
||||
|
||||
if ($from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($from && !$to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($teamId) {
|
||||
$whereClause[] = [
|
||||
'teamsFilter.id' => $teamId,
|
||||
];
|
||||
}
|
||||
|
||||
$queryBuilder
|
||||
->select([
|
||||
$stageField,
|
||||
['SUM:amountConverted', 'amount'],
|
||||
])
|
||||
->order(
|
||||
Order::createByPositionInList(
|
||||
Expression::column($stageField),
|
||||
$options
|
||||
)
|
||||
)
|
||||
->group($stageField)
|
||||
->where($whereClause);
|
||||
|
||||
if ($teamId) {
|
||||
$queryBuilder->join(Field::TEAMS, 'teamsFilter');
|
||||
}
|
||||
|
||||
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
|
||||
|
||||
$sth = $this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($queryBuilder->build());
|
||||
|
||||
$rowList = $sth->fetchAll() ?: [];
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($rowList as $row) {
|
||||
$stage = $row[$stageField];
|
||||
|
||||
$data[$stage] = floatval($row['amount']);
|
||||
}
|
||||
|
||||
$dataList = [];
|
||||
|
||||
$stageList = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
|
||||
|
||||
foreach ($stageList as $stage) {
|
||||
if (in_array($stage, $lostStageList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!in_array($stage, $lostStageList) && !isset($data[$stage])) {
|
||||
$data[$stage] = 0.0;
|
||||
}
|
||||
|
||||
$dataList[] = [
|
||||
'stage' => $stage,
|
||||
'value' => $data[$stage],
|
||||
];
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'dataList' => $dataList,
|
||||
];
|
||||
}
|
||||
}
|
||||
120
application/Espo/Modules/Crm/Tools/Opportunity/Report/Util.php
Normal file
120
application/Espo/Modules/Crm/Tools/Opportunity/Report/Util.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?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\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Opportunity as OpportunityEntity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
|
||||
class Util
|
||||
{
|
||||
private Metadata $metadata;
|
||||
private EntityManager $entityManager;
|
||||
|
||||
public function __construct(
|
||||
Metadata $metadata,
|
||||
EntityManager $entityManager
|
||||
) {
|
||||
$this->metadata = $metadata;
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* A grouping-by with distinct will give wrong results. Need to use sub-query.
|
||||
*
|
||||
* @param array<string|int, mixed> $whereClause
|
||||
*/
|
||||
public function handleDistinctReportQueryBuilder(SelectBuilder $queryBuilder, array $whereClause): void
|
||||
{
|
||||
if (!$queryBuilder->build()->isDistinct()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subQuery = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->select()
|
||||
->from(OpportunityEntity::ENTITY_TYPE)
|
||||
->select(Attribute::ID)
|
||||
->where($whereClause)
|
||||
->build();
|
||||
|
||||
$queryBuilder->where([
|
||||
'id=s' => $subQuery,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getLostStageList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
$probabilityMap = $this->metadata
|
||||
->get(['entityDefs', OpportunityEntity::ENTITY_TYPE, 'fields', 'stage', 'probabilityMap']) ?? [];
|
||||
|
||||
$stageList = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
|
||||
|
||||
foreach ($stageList as $stage) {
|
||||
$value = $probabilityMap[$stage] ?? 0;
|
||||
|
||||
if (!$value) {
|
||||
$list[] = $stage;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getWonStageList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
$probabilityMap = $this->metadata
|
||||
->get(['entityDefs', OpportunityEntity::ENTITY_TYPE, 'fields', 'stage', 'probabilityMap']) ?? [];
|
||||
|
||||
$stageList = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
|
||||
|
||||
foreach ($stageList as $stage) {
|
||||
$value = $probabilityMap[$stage] ?? 0;
|
||||
|
||||
if ($value == 100) {
|
||||
$list[] = $stage;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
218
application/Espo/Modules/Crm/Tools/Opportunity/Service.php
Normal file
218
application/Espo/Modules/Crm/Tools/Opportunity/Service.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?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\Modules\Crm\Tools\Opportunity;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Field\EmailAddress;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Modules\Crm\Entities\Account;
|
||||
use Espo\Modules\Crm\Entities\Contact;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\Email\EmailAddressEntityPair;
|
||||
use RuntimeException;
|
||||
|
||||
class Service
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private ServiceContainer $serviceContainer,
|
||||
private Acl $acl,
|
||||
private EntityManager $entityManager,
|
||||
private SelectBuilderFactory $selectBuilderFactory
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return EmailAddressEntityPair[]
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function getEmailAddressList(string $id): array
|
||||
{
|
||||
/** @var Opportunity $entity */
|
||||
$entity = $this->serviceContainer
|
||||
->get(Opportunity::ENTITY_TYPE)
|
||||
->getEntity($id);
|
||||
|
||||
$list = [];
|
||||
|
||||
if (
|
||||
$this->acl->checkField(Opportunity::ENTITY_TYPE, 'contacts') &&
|
||||
$this->acl->checkScope(Contact::ENTITY_TYPE)
|
||||
) {
|
||||
foreach ($this->getContactEmailAddressList($entity) as $item) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
$list === [] &&
|
||||
$this->acl->checkField(Opportunity::ENTITY_TYPE, 'account') &&
|
||||
$this->acl->checkScope(Account::ENTITY_TYPE)
|
||||
) {
|
||||
$item = $this->getAccountEmailAddress($entity, $list);
|
||||
|
||||
if ($item) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EmailAddressEntityPair[] $dataList
|
||||
*/
|
||||
private function getAccountEmailAddress(Opportunity $entity, array $dataList): ?EmailAddressEntityPair
|
||||
{
|
||||
$accountLink = $entity->getAccount();
|
||||
|
||||
if (!$accountLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ?Account $account */
|
||||
$account = $this->entityManager->getEntityById(Account::ENTITY_TYPE, $accountLink->getId());
|
||||
|
||||
if (!$account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$emailAddress = $account->getEmailAddress();
|
||||
|
||||
if (!$emailAddress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntity($account)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($dataList as $item) {
|
||||
if ($item->getEmailAddress()->getAddress() === $emailAddress) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new EmailAddressEntityPair(EmailAddress::create($emailAddress), $account);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EmailAddressEntityPair[]
|
||||
*/
|
||||
private function getContactEmailAddressList(Opportunity $entity): array
|
||||
{
|
||||
$contactsLinkMultiple = $entity->getContacts();
|
||||
|
||||
$contactIdList = $contactsLinkMultiple->getIdList();
|
||||
|
||||
if (!count($contactIdList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Contact::ENTITY_TYPE, 'emailAddress')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dataList = [];
|
||||
|
||||
$emailAddressList = [];
|
||||
|
||||
try {
|
||||
$query = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Contact::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder()
|
||||
->select([
|
||||
'id',
|
||||
'emailAddress',
|
||||
'name',
|
||||
])
|
||||
->where([
|
||||
'id' => $contactIdList,
|
||||
])
|
||||
->build();
|
||||
} catch (BadRequest|Forbidden $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
/** @var Collection<Contact> $contactCollection */
|
||||
$contactCollection = $this->entityManager
|
||||
->getRDBRepositoryByClass(Contact::class)
|
||||
->clone($query)
|
||||
->find();
|
||||
|
||||
foreach ($contactCollection as $contact) {
|
||||
$emailAddress = $contact->getEmailAddress();
|
||||
|
||||
if (!$emailAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($emailAddress, $emailAddressList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$emailAddressList[] = $emailAddress;
|
||||
|
||||
$dataList[] = new EmailAddressEntityPair(EmailAddress::create($emailAddress), $contact);
|
||||
}
|
||||
|
||||
$contact = $entity->getContact();
|
||||
|
||||
if (!$contact) {
|
||||
return $dataList;
|
||||
}
|
||||
|
||||
usort(
|
||||
$dataList,
|
||||
function (
|
||||
EmailAddressEntityPair $o1,
|
||||
EmailAddressEntityPair $o2
|
||||
) use ($contact) {
|
||||
if ($o1->getEntity()->getId() === $contact->getId()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ($o2->getEntity()->getId() === $contact->getId()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
|
||||
return $dataList;
|
||||
}
|
||||
}
|
||||
@@ -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\Modules\Crm\Tools\Reminder\Sender;
|
||||
|
||||
use Espo\Core\Mail\Exceptions\SendingError;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Entities\Meeting;
|
||||
use Espo\Modules\Crm\Entities\Reminder;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\Utils\Util;
|
||||
use Espo\Core\Htmlizer\HtmlizerFactory as HtmlizerFactory;
|
||||
use Espo\Core\Mail\EmailSender;
|
||||
use Espo\Core\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Utils\TemplateFileManager;
|
||||
use RuntimeException;
|
||||
|
||||
class EmailReminder
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private TemplateFileManager $templateFileManager,
|
||||
private EmailSender $emailSender,
|
||||
private Config $config,
|
||||
private HtmlizerFactory $htmlizerFactory,
|
||||
private Language $language
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws SendingError
|
||||
*/
|
||||
public function send(Reminder $reminder): void
|
||||
{
|
||||
$entityType = $reminder->getTargetEntityType();
|
||||
$entityId = $reminder->getTargetEntityId();
|
||||
$userId = $reminder->getUserId();
|
||||
|
||||
if (!$entityType || !$entityId || !$userId) {
|
||||
throw new RuntimeException("Bad reminder.");
|
||||
}
|
||||
|
||||
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
|
||||
$entity = $this->entityManager->getEntityById($entityType, $entityId);
|
||||
|
||||
if (
|
||||
!$user ||
|
||||
!$entity instanceof CoreEntity ||
|
||||
!$user->getEmailAddress()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
$entity->hasLinkMultipleField('users') &&
|
||||
$entity->hasAttribute('usersColumns')
|
||||
) {
|
||||
$status = $entity->getLinkMultipleColumn('users', 'status', $user->getId());
|
||||
|
||||
if ($status === Meeting::ATTENDEE_STATUS_DECLINED) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
[$subject, $body] = $this->getSubjectBody($entity, $user);
|
||||
|
||||
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
|
||||
|
||||
$email->addToAddress($user->getEmailAddress());
|
||||
$email->setSubject($subject);
|
||||
$email->setBody($body);
|
||||
$email->setIsHtml();
|
||||
|
||||
$this->emailSender->send($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function getTemplates(CoreEntity $entity): array
|
||||
{
|
||||
$subjectTpl = $this->templateFileManager
|
||||
->getTemplate('reminder', 'subject', $entity->getEntityType(), 'Crm');
|
||||
|
||||
$bodyTpl = $this->templateFileManager
|
||||
->getTemplate('reminder', 'body', $entity->getEntityType(), 'Crm');
|
||||
|
||||
return [$subjectTpl, $bodyTpl];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function getSubjectBody(CoreEntity $entity, User $user): array
|
||||
{
|
||||
$entityType = $entity->getEntityType();
|
||||
$entityId = $entity->getId();
|
||||
|
||||
[$subjectTpl, $bodyTpl] = $this->getTemplates($entity);
|
||||
|
||||
$subjectTpl = str_replace(["\n", "\r"], '', $subjectTpl);
|
||||
|
||||
$siteUrl = rtrim($this->config->get('siteUrl'), '/');
|
||||
$translatedEntityType = $this->language->translateLabel($entityType, 'scopeNames');
|
||||
|
||||
$data = [
|
||||
'recordUrl' => "$siteUrl/#$entityType/view/$entityId",
|
||||
'entityType' => $translatedEntityType,
|
||||
'entityTypeLowerFirst' => Util::mbLowerCaseFirst($translatedEntityType),
|
||||
'userName' => $user->getName(),
|
||||
];
|
||||
|
||||
$htmlizer = $this->htmlizerFactory->createForUser($user);
|
||||
|
||||
$subject = $htmlizer->render(
|
||||
$entity,
|
||||
$subjectTpl,
|
||||
'reminder-email-subject-' . $entityType,
|
||||
$data,
|
||||
true
|
||||
);
|
||||
|
||||
$body = $htmlizer->render(
|
||||
$entity,
|
||||
$bodyTpl,
|
||||
'reminder-email-body-' . $entityType,
|
||||
$data,
|
||||
false
|
||||
);
|
||||
|
||||
return [$subject, $body];
|
||||
}
|
||||
}
|
||||
@@ -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\Modules\Crm\Tools\TargetList\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Record\SearchParamsFetcher;
|
||||
use Espo\Modules\Crm\Entities\TargetList;
|
||||
use Espo\Modules\Crm\Tools\TargetList\OptOutService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class GetOptedOut implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private Acl $acl,
|
||||
private OptOutService $service
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id');
|
||||
|
||||
if (!$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
if (!$this->acl->check(TargetList::ENTITY_TYPE)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$searchParams = $this->searchParamsFetcher->fetch($request);
|
||||
|
||||
$result = $this->service->find($id, $searchParams);
|
||||
|
||||
return ResponseComposer::json($result->toApiOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?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\Modules\Crm\Tools\TargetList;
|
||||
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\TargetList;
|
||||
use Espo\ORM\Defs;
|
||||
|
||||
class MetadataProvider
|
||||
{
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
private Defs $defs
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getTargetLinkList(): array
|
||||
{
|
||||
return $this->metadata->get(['scopes', 'TargetList', 'targetLinkList']) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getEntityTypeLinkMap(): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach ($this->getTargetLinkList() as $link) {
|
||||
$entityType = $this->defs
|
||||
->getEntity(TargetList::ENTITY_TYPE)
|
||||
->getRelation($link)
|
||||
->getForeignEntityType();
|
||||
|
||||
$map[$entityType] = $link;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
258
application/Espo/Modules/Crm/Tools/TargetList/OptOutService.php
Normal file
258
application/Espo/Modules/Crm/Tools/TargetList/OptOutService.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?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\Modules\Crm\Tools\TargetList;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\HookManager;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Record\Collection;
|
||||
use Espo\Core\Record\Collection as RecordCollection;
|
||||
use Espo\Core\Record\EntityProvider;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Modules\Crm\Entities\TargetList;
|
||||
use Espo\ORM\Defs\Params\RelationParam;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use Espo\ORM\Query\Select;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
class OptOutService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private MetadataProvider $metadataProvider,
|
||||
private EntityProvider $entityProvider,
|
||||
private HookManager $hookManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Opt out a target.
|
||||
*
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function optOut(string $id, string $targetType, string $targetId): void
|
||||
{
|
||||
$targetList = $this->entityProvider->getByClass(TargetList::class, $id);
|
||||
|
||||
$target = $this->entityManager->getEntityById($targetType, $targetId);
|
||||
|
||||
if (!$target) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
$map = $this->metadataProvider->getEntityTypeLinkMap();
|
||||
|
||||
if (empty($map[$targetType])) {
|
||||
throw new Forbidden("Not supported target type.");
|
||||
}
|
||||
|
||||
$link = $map[$targetType];
|
||||
|
||||
$this->entityManager
|
||||
->getRDBRepository(TargetList::ENTITY_TYPE)
|
||||
->getRelation($targetList, $link)
|
||||
->relateById($targetId, ['optedOut' => true]);
|
||||
|
||||
$hookData = [
|
||||
'link' => $link,
|
||||
'targetId' => $targetId,
|
||||
'targetType' => $targetType,
|
||||
];
|
||||
|
||||
$this->hookManager->process(TargetList::ENTITY_TYPE, 'afterOptOut', $targetList, [], $hookData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel opt-out for a target.
|
||||
*
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function cancelOptOut(string $id, string $targetType, string $targetId): void
|
||||
{
|
||||
$targetList = $this->entityProvider->getByClass(TargetList::class, $id);
|
||||
|
||||
$target = $this->entityManager->getEntityById($targetType, $targetId);
|
||||
|
||||
if (!$target) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
$map = $this->metadataProvider->getEntityTypeLinkMap();
|
||||
|
||||
if (empty($map[$targetType])) {
|
||||
throw new Forbidden("Not supported target type.");
|
||||
}
|
||||
|
||||
$link = $map[$targetType];
|
||||
|
||||
$this->entityManager
|
||||
->getRDBRepository(TargetList::ENTITY_TYPE)
|
||||
->getRelation($targetList, $link)
|
||||
->updateColumnsById($targetId, ['optedOut' => false]);
|
||||
|
||||
$hookData = [
|
||||
'link' => $link,
|
||||
'targetId' => $targetId,
|
||||
'targetType' => $targetType,
|
||||
];
|
||||
|
||||
$this->hookManager->process('TargetList', TargetList::ENTITY_TYPE, $targetList, [], $hookData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find opted out targets in a target list.
|
||||
*
|
||||
* @return Collection<Entity>
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function find(string $id, SearchParams $params): Collection
|
||||
{
|
||||
$this->checkEntity($id);
|
||||
|
||||
$offset = $params->getOffset() ?? 0;
|
||||
$maxSize = $params->getMaxSize() ?? 0;
|
||||
|
||||
$em = $this->entityManager;
|
||||
$queryBuilder = $em->getQueryBuilder();
|
||||
|
||||
$queryList = [];
|
||||
|
||||
$targetLinkList = $this->metadataProvider->getTargetLinkList();
|
||||
|
||||
foreach ($targetLinkList as $link) {
|
||||
$queryList[] = $this->getSelectQueryForLink($id, $link);
|
||||
}
|
||||
|
||||
$builder = $queryBuilder
|
||||
->union()
|
||||
->all();
|
||||
|
||||
foreach ($queryList as $query) {
|
||||
$builder->query($query);
|
||||
}
|
||||
|
||||
$countQuery = $queryBuilder
|
||||
->select()
|
||||
->fromQuery($builder->build(), 'c')
|
||||
->select('COUNT:(c.id)', 'count')
|
||||
->build();
|
||||
|
||||
$row = $em->getQueryExecutor()
|
||||
->execute($countQuery)
|
||||
->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$totalCount = $row['count'];
|
||||
|
||||
$unionQuery = $builder
|
||||
->limit($offset, $maxSize)
|
||||
->order(Field::CREATED_AT, 'DESC')
|
||||
->build();
|
||||
|
||||
$sth = $em->getQueryExecutor()->execute($unionQuery);
|
||||
|
||||
$collection = $this->entityManager
|
||||
->getCollectionFactory()
|
||||
->create();
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
$itemEntity = $this->entityManager->getNewEntity($row['entityType']);
|
||||
|
||||
$itemEntity->set($row);
|
||||
$itemEntity->setAsFetched();
|
||||
|
||||
$collection[] = $itemEntity;
|
||||
}
|
||||
|
||||
/** @var RecordCollection<Entity> */
|
||||
return new RecordCollection($collection, $totalCount);
|
||||
}
|
||||
|
||||
private function getSelectQueryForLink(string $id, string $link): Select
|
||||
{
|
||||
$seed = $this->entityManager->getRDBRepositoryByClass(TargetList::class)->getNew();
|
||||
|
||||
$entityType = $seed->getRelationParam($link, RelationParam::ENTITY);
|
||||
|
||||
if (!$entityType) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$linkEntityType = ucfirst(
|
||||
$seed->getRelationParam($link, RelationParam::RELATION_NAME) ?? ''
|
||||
);
|
||||
|
||||
if ($linkEntityType === '') {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$key = $seed->getRelationParam($link, RelationParam::MID_KEYS)[1] ?? null;
|
||||
|
||||
if (!$key) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
return $this->entityManager->getQueryBuilder()
|
||||
->select()
|
||||
->from($entityType)
|
||||
->select([
|
||||
'id',
|
||||
'name',
|
||||
Field::CREATED_AT,
|
||||
["'$entityType'", 'entityType'],
|
||||
])
|
||||
->join(
|
||||
$linkEntityType,
|
||||
'j',
|
||||
[
|
||||
"j.$key:" => 'id',
|
||||
'j.deleted' => false,
|
||||
'j.optedOut' => true,
|
||||
'j.targetListId' => $id,
|
||||
]
|
||||
)
|
||||
->order(Field::CREATED_AT, Order::DESC)
|
||||
->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function checkEntity(string $id): void
|
||||
{
|
||||
$this->entityProvider->getByClass(TargetList::class, $id);
|
||||
}
|
||||
}
|
||||
120
application/Espo/Modules/Crm/Tools/TargetList/RecordService.php
Normal file
120
application/Espo/Modules/Crm/Tools/TargetList/RecordService.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?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\Modules\Crm\Tools\TargetList;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Acl\Table;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\HookManager;
|
||||
use Espo\Modules\Crm\Entities\TargetList;
|
||||
use Espo\ORM\Defs\Params\RelationParam;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use RuntimeException;
|
||||
|
||||
class RecordService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Acl $acl,
|
||||
private HookManager $hookManager,
|
||||
private MetadataProvider $metadataProvider
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Unlink all targets.
|
||||
*
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function unlinkAll(string $id, string $link): void
|
||||
{
|
||||
$entity = $this->getEntity($id);
|
||||
|
||||
$linkEntityType = $this->getLinkEntityType($entity, $link);
|
||||
|
||||
$updateQuery = $this->entityManager->getQueryBuilder()
|
||||
->update()
|
||||
->in($linkEntityType)
|
||||
->set([Attribute::DELETED => true])
|
||||
->where(['targetListId' => $entity->getId()])
|
||||
->build();
|
||||
|
||||
$this->entityManager->getQueryExecutor()->execute($updateQuery);
|
||||
|
||||
$this->hookManager->process(TargetList::ENTITY_TYPE, 'afterUnlinkAll', $entity, [], ['link' => $link]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getEntity(string $id): TargetList
|
||||
{
|
||||
$entity = $this->entityManager->getRDBRepositoryByClass(TargetList::class)->getById($id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->check($entity, Table::ACTION_EDIT)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
*/
|
||||
private function getLinkEntityType(TargetList $entity, string $link): string
|
||||
{
|
||||
if (!in_array($link, $this->metadataProvider->getTargetLinkList())) {
|
||||
throw new BadRequest("Not supported link.");
|
||||
}
|
||||
|
||||
$foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY);
|
||||
|
||||
if (!$foreignEntityType) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$linkEntityType = ucfirst($entity->getRelationParam($link, RelationParam::RELATION_NAME) ?? '');
|
||||
|
||||
if ($linkEntityType === '') {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
return $linkEntityType;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user