diff --git a/application/Espo/Modules/Crm/Classes/FieldValidators/Meeting/ExternalService/Valid.php b/application/Espo/Modules/Crm/Classes/FieldValidators/Meeting/ExternalService/Valid.php new file mode 100644 index 0000000000..6f9e63c8f2 --- /dev/null +++ b/application/Espo/Modules/Crm/Classes/FieldValidators/Meeting/ExternalService/Valid.php @@ -0,0 +1,62 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Modules\Crm\Classes\FieldValidators\Meeting\ExternalService; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Metadata; +use Espo\Modules\Crm\Entities\Meeting; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function __construct( + private Metadata $metadata, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $service = $entity->getExternalService(); + + if (!$service) { + return null; + } + + if ($this->metadata->get("app.meetingServices.$service.enabled")) { + return null; + } + + return Failure::create(); + } +} diff --git a/application/Espo/Modules/Crm/Classes/RecordHooks/Meeting/BeforeCreateExternalServiceCheck.php b/application/Espo/Modules/Crm/Classes/RecordHooks/Meeting/BeforeCreateExternalServiceCheck.php new file mode 100644 index 0000000000..dcedee226a --- /dev/null +++ b/application/Espo/Modules/Crm/Classes/RecordHooks/Meeting/BeforeCreateExternalServiceCheck.php @@ -0,0 +1,70 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Modules\Crm\Classes\RecordHooks\Meeting; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Meeting; +use Espo\Modules\Crm\Tools\Meeting\MeetingServiceAvailabilityCheckerFactory; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class BeforeCreateExternalServiceCheck implements SaveHook +{ + public function __construct( + private Metadata $metadata, + private MeetingServiceAvailabilityCheckerFactory $factory, + private User $user, + ) {} + + public function process(Entity $entity): void + { + $service = $entity->getExternalService(); + + if (!$service) { + return; + } + + if (!$this->metadata->get("app.meetingServices.$service.enabled")) { + throw new BadRequest("Not supported service '$service'."); + } + + $checker = $this->factory->create($service); + + if (!$checker->check($this->user)) { + throw new Forbidden("Not allowed service '$service'."); + } + } +} diff --git a/application/Espo/Modules/Crm/Entities/Meeting.php b/application/Espo/Modules/Crm/Entities/Meeting.php index 896da3d48b..eb3943d271 100644 --- a/application/Espo/Modules/Crm/Entities/Meeting.php +++ b/application/Espo/Modules/Crm/Entities/Meeting.php @@ -247,4 +247,12 @@ class Meeting extends Entity { return $this->get('joinUrl'); } + + /** + * @since 9.4.0 + */ + public function getExternalService(): ?string + { + return $this->get('externalService'); + } } diff --git a/application/Espo/Modules/Crm/Resources/i18n/en_US/Meeting.json b/application/Espo/Modules/Crm/Resources/i18n/en_US/Meeting.json index 18bb1e746a..7ecdfbd037 100644 --- a/application/Espo/Modules/Crm/Resources/i18n/en_US/Meeting.json +++ b/application/Espo/Modules/Crm/Resources/i18n/en_US/Meeting.json @@ -18,7 +18,8 @@ "isAllDay": "Is All-Day", "sourceEmail": "Source Email", "uid": "UID", - "joinUrl": "Join URL" + "joinUrl": "Join URL", + "externalService": "Online Location" }, "links": {}, "options": { diff --git a/application/Espo/Modules/Crm/Resources/layouts/Meeting/defaultSidePanel.json b/application/Espo/Modules/Crm/Resources/layouts/Meeting/defaultSidePanel.json new file mode 100644 index 0000000000..4a0610cf93 --- /dev/null +++ b/application/Espo/Modules/Crm/Resources/layouts/Meeting/defaultSidePanel.json @@ -0,0 +1,11 @@ +[ + { + "name": ":assignedUser" + }, + { + "name": "teams" + }, + { + "name": "externalService" + } +] diff --git a/application/Espo/Modules/Crm/Resources/metadata/app/appParams.json b/application/Espo/Modules/Crm/Resources/metadata/app/appParams.json new file mode 100644 index 0000000000..23ffe652a9 --- /dev/null +++ b/application/Espo/Modules/Crm/Resources/metadata/app/appParams.json @@ -0,0 +1,6 @@ +{ + "meetingServices": { + "className": "Espo\\Modules\\Crm\\Tools\\Meeting\\MeetingServicesAppParam", + "enabled": true + } +} diff --git a/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Meeting.json b/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Meeting.json index 7b41fe2ff9..47946a2819 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Meeting.json +++ b/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Meeting.json @@ -15,11 +15,13 @@ "viewSetupHandlers": { "record/detail": [ "__APPEND__", - "crm:handlers/event/reminders-handler" + "crm:handlers/event/reminders-handler", + "modules/crm/handlers/meeting/external-service" ], "record/edit": [ "__APPEND__", - "crm:handlers/event/reminders-handler" + "crm:handlers/event/reminders-handler", + "modules/crm/handlers/meeting/external-service" ] }, "sidePanels":{ diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Meeting.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Meeting.json index 4947de8cae..aab122a86e 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Meeting.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Meeting.json @@ -96,6 +96,20 @@ "default": null, "customizationDefaultDisabled": true }, + "externalService": { + "type": "enum", + "maxLength": 100, + "readOnlyAfterCreate": true, + "fieldManagerParamList": [ + "tooltipText" + ], + "isSorted": true, + "validatorClassNameList": [ + "Espo\\Modules\\Crm\\Classes\\FieldValidators\\Meeting\\ExternalService\\Valid" + ], + "view": "modules/crm/views/meeting/fields/external-service", + "dynamicLogicDisabled": true + }, "acceptanceStatus": { "type": "enum", "notStorable": true, diff --git a/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Meeting.json b/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Meeting.json index d4e578f029..6d6a36eea5 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Meeting.json +++ b/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Meeting.json @@ -9,6 +9,9 @@ "Espo\\Core\\FieldProcessing\\Reminder\\Saver", "Espo\\Modules\\Crm\\Classes\\FieldProcessing\\Meeting\\SourceEmailSaver" ], + "earlyBeforeCreateHookClassNameList": [ + "Espo\\Modules\\Crm\\Classes\\RecordHooks\\Meeting\\BeforeCreateExternalServiceCheck" + ], "beforeCreateHookClassNameList": [ "Espo\\Modules\\Crm\\Classes\\RecordHooks\\Meeting\\BeforeCreateSourceEmailCheck" ], diff --git a/application/Espo/Modules/Crm/Tools/Meeting/MeetingServiceAvailabilityChecker.php b/application/Espo/Modules/Crm/Tools/Meeting/MeetingServiceAvailabilityChecker.php new file mode 100644 index 0000000000..285f518aad --- /dev/null +++ b/application/Espo/Modules/Crm/Tools/Meeting/MeetingServiceAvailabilityChecker.php @@ -0,0 +1,42 @@ +. + * + * 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\Entities\User; + +/** + * Checks whether an online meeting can be created. + * + * @since 9.4.0 + */ +interface MeetingServiceAvailabilityChecker +{ + public function check(User $user): bool; +} diff --git a/application/Espo/Modules/Crm/Tools/Meeting/MeetingServiceAvailabilityCheckerFactory.php b/application/Espo/Modules/Crm/Tools/Meeting/MeetingServiceAvailabilityCheckerFactory.php new file mode 100644 index 0000000000..345fab8c7a --- /dev/null +++ b/application/Espo/Modules/Crm/Tools/Meeting/MeetingServiceAvailabilityCheckerFactory.php @@ -0,0 +1,54 @@ +. + * + * 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\InjectableFactory; +use Espo\Core\Utils\Metadata; +use RuntimeException; + +class MeetingServiceAvailabilityCheckerFactory +{ + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory, + ) {} + + public function create(string $name): MeetingServiceAvailabilityChecker + { + /** @var ?class-string $className */ + $className = $this->metadata->get("app.meetingServices.$name.availabilityCheckerClassName"); + + if (!$className) { + throw new RuntimeException("Service '$name' not available."); + } + + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Modules/Crm/Tools/Meeting/MeetingServicesAppParam.php b/application/Espo/Modules/Crm/Tools/Meeting/MeetingServicesAppParam.php new file mode 100644 index 0000000000..6cbbc34692 --- /dev/null +++ b/application/Espo/Modules/Crm/Tools/Meeting/MeetingServicesAppParam.php @@ -0,0 +1,77 @@ +. + * + * 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\Utils\Metadata; +use Espo\Entities\User; +use Espo\Tools\App\AppParam; + +/** + * @noinspection PhpUnused + */ +class MeetingServicesAppParam implements AppParam +{ + public function __construct( + private Metadata $metadata, + private User $user, + private MeetingServiceAvailabilityCheckerFactory $factory, + ) {} + + /** + * @return array + */ + public function get(): array + { + $output = []; + + /** @var array> $services */ + $services = $this->metadata->get("app.meetingServices") ?? []; + + foreach ($services as $name => $item) { + $enabled = $item['enabled'] ?? false; + + if (!$enabled) { + continue; + } + + $checker = $this->factory->create($name); + + if (!$checker->check($this->user)) { + continue; + } + + $output[] = [ + 'name' => $name + ]; + } + + return $output; + } +} diff --git a/client/modules/crm/src/handlers/meeting/external-service.js b/client/modules/crm/src/handlers/meeting/external-service.js new file mode 100644 index 0000000000..167e5d0f03 --- /dev/null +++ b/client/modules/crm/src/handlers/meeting/external-service.js @@ -0,0 +1,72 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2026 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 . + * + * 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. + ************************************************************************/ + +import {inject} from 'di'; +import AppParams from 'app-params'; + +// noinspection JSUnusedGlobalSymbols +export default class MeetingExternalServiceHandler{ + + /** + * @private + * @type {AppParams} + */ + @inject(AppParams) + appParams + + /** + * @param {import('views/record/detail').default} view + */ + constructor(view) { + this.view = view; + } + + process() { + this.controlField(); + this.view.listenTo(this.view.model, 'change:externalService', () => this.controlField()); + } + + /** + * @private + */ + controlField() { + const model = this.view.model; + + if (model.attributes.externalService) { + this.view.showField('externalService'); + + return; + } + + const list = this.appParams.get('meetingServices') ?? []; + + if (!list.length) { + this.view.hideField('externalService'); + } + } +} diff --git a/client/modules/crm/src/views/meeting/fields/external-service.js b/client/modules/crm/src/views/meeting/fields/external-service.js new file mode 100644 index 0000000000..e738305faf --- /dev/null +++ b/client/modules/crm/src/views/meeting/fields/external-service.js @@ -0,0 +1,51 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2026 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 . + * + * 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. + ************************************************************************/ + +import EnumFieldView from 'views/fields/enum'; +import {inject} from 'di'; +import AppParams from 'app-params'; + +// noinspection JSUnusedGlobalSymbols +export default class ExternalServiceFieldView extends EnumFieldView { + + /** + * @private + * @type {AppParams} + */ + @inject(AppParams) + appParams + + setupOptions() { + /** @type {{name: string}[]} */ + const list = this.appParams.get('meetingServices') ?? []; + + this.params.options = list.map(it => it.name); + + this.params.options.unshift('') + } +}