diff --git a/application/Espo/Core/Record/Access/LinkCheck.php b/application/Espo/Core/Record/Access/LinkCheck.php index adc8413f5c..ae066ffae3 100644 --- a/application/Espo/Core/Record/Access/LinkCheck.php +++ b/application/Espo/Core/Record/Access/LinkCheck.php @@ -320,6 +320,17 @@ class LinkCheck * @throws Forbidden */ public function processLinkForeign(Entity $entity, string $link, Entity $foreignEntity): void + { + $this->processLinkForeignInternal($entity, $link, $foreignEntity); + $this->processLinkAlreadyLinkedCheck($entity, $link, $foreignEntity); + } + + /** + * Check link access for a specific foreign entity. + * + * @throws Forbidden + */ + private function processLinkForeignInternal(Entity $entity, string $link, Entity $foreignEntity): void { $toSkip = $this->linkForeignAccessCheckMany($entity->getEntityType(), $link, $foreignEntity); @@ -337,7 +348,7 @@ class LinkCheck */ public function processUnlinkForeign(Entity $entity, string $link, Entity $foreignEntity): void { - $this->processLinkForeign($entity, $link, $foreignEntity); + $this->processLinkForeignInternal($entity, $link, $foreignEntity); $this->processUnlinkForeignRequired($entity, $link, $foreignEntity); } @@ -697,4 +708,41 @@ class LinkCheck return $names; } + + /** + * @throws Forbidden + */ + private function processLinkAlreadyLinkedCheck(Entity $entity, string $link, Entity $foreignEntity): void + { + if (!$this->getParam($entity->getEntityType(), $link, 'linkOnlyNotLinked')) { + return; + } + + $entityType = $entity->getEntityType(); + + $foreign = $this->ormDefs + ->getEntity($entityType) + ->tryGetRelation($link) + ?->tryGetForeignRelationName(); + + if (!$foreign) { + return; + } + + $one = $this->entityManager + ->getRDBRepository($foreignEntity->getEntityType()) + ->getRelation($foreignEntity, $foreign) + ->findOne(); + + if (!$one) { + return; + } + + throw ForbiddenSilent::createWithBody( + "Cannot link as the record is already linked ($entityType:$link).", + ErrorBody::create() + ->withMessageTranslation('cannotLinkAlreadyLinked') + ->encode() + ); + } } diff --git a/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Account.json b/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Account.json index 98b275d396..d9fdd8a683 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Account.json +++ b/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Account.json @@ -6,6 +6,9 @@ }, "targetLists": { "mandatoryAttributeList": ["isOptedOut"] + }, + "opportunities": { + "linkOnlyNotLinked": true } } } diff --git a/application/Espo/ORM/Defs/RelationDefs.php b/application/Espo/ORM/Defs/RelationDefs.php index fdf35176b0..7abef739cc 100644 --- a/application/Espo/ORM/Defs/RelationDefs.php +++ b/application/Espo/ORM/Defs/RelationDefs.php @@ -169,6 +169,20 @@ class RelationDefs return isset($this->data['foreign']); } + /** + * Try to get a foreign relation name. + * + * @since 8.3.0 + */ + public function tryGetForeignRelationName(): ?string + { + if (!$this->hasForeignRelationName()) { + return null; + } + + return $this->getForeignRelationName(); + } + /** * Get a foreign relation name. * diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 62de63e243..569798285d 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -397,6 +397,7 @@ "cannotRelateNonExisting": "Can't relate with non-existing {foreignEntityType} record.", "cannotRelateForbidden": "Can't relate with forbidden {foreignEntityType} record. `{action}` access required.", "cannotRelateForbiddenLink": "No access to link '{link}'.", + "cannotLinkAlreadyLinked": "Cannot link an already linked record.", "error404": "The url you requested can't be handled.", "error403": "You don't have an access to this area.", "emptyMassUpdate": "No fields available for Mass Update.", diff --git a/schema/metadata/recordDefs.json b/schema/metadata/recordDefs.json index 10d5db30fa..f6c61e3067 100644 --- a/schema/metadata/recordDefs.json +++ b/schema/metadata/recordDefs.json @@ -158,6 +158,10 @@ "massLink": { "type": "boolean", "default": "Allow mass link. As of v8.2." + }, + "linkOnlyNotLinked": { + "type": "boolean", + "description": "Allow linking only if a record is not linked with any. As of v8.3." } } }