fix: clean up manager logic
Some checks failed
Build Test / test (pull_request) Successful in 30s
JS Unit Tests / test (pull_request) Failing after 29s
PHP Unit Tests / test (pull_request) Successful in 50s

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-05-15 19:06:06 -04:00
parent 493dd1c015
commit 72c2972777
3 changed files with 222 additions and 148 deletions

View File

@@ -316,8 +316,8 @@ class DefaultController extends ControllerAbstract {
$userId, $userId,
$data['provider'], $data['provider'],
$data['identifier'], $data['identifier'],
$data['data'],
$data['delta'] ?? false, $data['delta'] ?? false,
$data['data']
); );
} }
@@ -515,7 +515,7 @@ class DefaultController extends ControllerAbstract {
$userId, $userId,
$data['provider'], $data['provider'],
$data['service'], $data['service'],
$targetIdentifier->collection() ?? null, $targetIdentifier ?? null,
$data['properties'] $data['properties']
); );
} }
@@ -807,7 +807,7 @@ class DefaultController extends ControllerAbstract {
} }
} }
return $this->mailManager->entityDelete($tenantId, $userId, $sources); return $this->mailManager->entityDelete($tenantId, $userId, ...$sources->all());
} }
private function entityPatch(string $tenantId, string $userId, array $data): mixed { private function entityPatch(string $tenantId, string $userId, array $data): mixed {
@@ -860,7 +860,7 @@ class DefaultController extends ControllerAbstract {
} }
} }
return $this->mailManager->entityMove($tenantId, $userId, $target, $sources); return $this->mailManager->entityMove($tenantId, $userId, $target, ...$sources->all());
} }
private function entityTransmit(string $tenantId, string $userId, array $data): mixed { private function entityTransmit(string $tenantId, string $userId, array $data): mixed {

View File

@@ -7,19 +7,14 @@ namespace KTXM\MailManager;
use InvalidArgumentException; use InvalidArgumentException;
use KTXC\Resource\ProviderManager; use KTXC\Resource\ProviderManager;
use KTXF\Mail\Collection\CollectionBaseInterface; use KTXF\Mail\Collection\CollectionBaseInterface;
use KTXF\Mail\Collection\CollectionMutableInterface;
use KTXF\Mail\Collection\CollectionPropertiesMutableInterface; use KTXF\Mail\Collection\CollectionPropertiesMutableInterface;
use KTXF\Mail\Collection\ICollectionBase; use KTXF\Mail\Collection\ICollectionBase;
use KTXF\Mail\Entity\Address;
use KTXF\Mail\Entity\IMessageBase; use KTXF\Mail\Entity\IMessageBase;
use KTXF\Mail\Entity\IMessageMutable; use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Exception\SendException;
use KTXF\Mail\Provider\ProviderBaseInterface; use KTXF\Mail\Provider\ProviderBaseInterface;
use KTXF\Mail\Provider\ProviderServiceDiscoverInterface; use KTXF\Mail\Provider\ProviderServiceDiscoverInterface;
use KTXF\Mail\Provider\ProviderServiceMutateInterface; use KTXF\Mail\Provider\ProviderServiceMutateInterface;
use KTXF\Mail\Provider\ProviderServiceTestInterface; use KTXF\Mail\Provider\ProviderServiceTestInterface;
use KTXF\Mail\Queue\SendOptions;
use KTXF\Mail\Service\IServiceSend;
use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Mail\Service\ServiceCollectionMutableInterface; use KTXF\Mail\Service\ServiceCollectionMutableInterface;
use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceConfigurableInterface;
@@ -28,11 +23,9 @@ use KTXF\Mail\Service\ServiceMutableInterface;
use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Identifier\ResourceIdentifiers;
use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Range\IRange;
use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeAnchorType;
use KTXF\Resource\Range\RangeType; use KTXF\Resource\Range\RangeType;
use KTXF\Resource\Selector\CollectionSelector; use KTXF\Resource\Selector\CollectionSelector;
@@ -40,7 +33,6 @@ use KTXF\Resource\Selector\EntitySelector;
use KTXF\Resource\Selector\ServiceSelector; use KTXF\Resource\Selector\ServiceSelector;
use KTXF\Resource\Selector\SourceSelector; use KTXF\Resource\Selector\SourceSelector;
use KTXF\Resource\Sort\ISort; use KTXF\Resource\Sort\ISort;
use KTXM\MailManager\Queue\MailQueueFile;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
/** /**
@@ -53,7 +45,6 @@ class Manager {
public function __construct( public function __construct(
private LoggerInterface $logger, private LoggerInterface $logger,
private ProviderManager $providerManager, private ProviderManager $providerManager,
private MailQueueFile $queue,
) { } ) { }
/** /**
@@ -227,6 +218,9 @@ class Manager {
if ($provider instanceof ProviderServiceMutateInterface === false) { if ($provider instanceof ProviderServiceMutateInterface === false) {
throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); throw new InvalidArgumentException("Provider '$providerId' does not support service creation");
} }
if ($provider->capable(ProviderServiceMutateInterface::CAPABILITY_SERVICE_CREATE) === false) {
throw new InvalidArgumentException("Provider '$providerId' is not capable of creating services");
}
// Create a fresh service instance // Create a fresh service instance
$service = $provider->serviceFresh(); $service = $provider->serviceFresh();
@@ -250,19 +244,22 @@ class Manager {
* @param string $userId User identifier for context * @param string $userId User identifier for context
* @param string $providerId Provider identifier * @param string $providerId Provider identifier
* @param string|int $serviceId Service identifier * @param string|int $serviceId Service identifier
* @param bool $delta Whether the update is a delta (partial) update or a full replacement
* @param array $data Updated service configuration data * @param array $data Updated service configuration data
* @param bool $delta Whether the update is a delta (partial) update or a full replacement
* *
* @return ServiceBaseInterface Updated service * @return ServiceBaseInterface Updated service
* *
* @throws InvalidArgumentException If provider doesn't support service modification or service not found * @throws InvalidArgumentException If provider doesn't support service modification or service not found
*/ */
public function serviceUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, bool $delta = false, array $data): ServiceBaseInterface { public function serviceUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, array $data, bool $delta = false): ServiceBaseInterface {
// retrieve provider and service // retrieve provider and service
$provider = $this->providerFetch($tenantId, $userId, $providerId); $provider = $this->providerFetch($tenantId, $userId, $providerId);
if ($provider instanceof ProviderServiceMutateInterface === false) { if ($provider instanceof ProviderServiceMutateInterface === false) {
throw new InvalidArgumentException("Provider '$providerId' does not support service modification"); throw new InvalidArgumentException("Provider '$providerId' does not support service modification");
} }
if ($provider->capable(ProviderServiceMutateInterface::CAPABILITY_SERVICE_MODIFY) === false) {
throw new InvalidArgumentException("Provider '$providerId' is not capable of modifying services");
}
// Fetch existing service // Fetch existing service
$service = $provider->serviceFetch($tenantId, $userId, $serviceId); $service = $provider->serviceFetch($tenantId, $userId, $serviceId);
@@ -303,6 +300,9 @@ class Manager {
if ($provider instanceof ProviderServiceMutateInterface === false) { if ($provider instanceof ProviderServiceMutateInterface === false) {
throw new InvalidArgumentException("Provider '$providerId' does not support service deletion"); throw new InvalidArgumentException("Provider '$providerId' does not support service deletion");
} }
if ($provider->capable(ProviderServiceMutateInterface::CAPABILITY_SERVICE_DESTROY) === false) {
throw new InvalidArgumentException("Provider '$providerId' is not capable of deleting services");
}
// Fetch existing service // Fetch existing service
$service = $provider->serviceFetch($tenantId, $userId, $serviceId); $service = $provider->serviceFetch($tenantId, $userId, $serviceId);
@@ -339,7 +339,7 @@ class Manager {
$providers = $this->providerList($tenantId, $userId, $providerId !== null ? new SourceSelector([$providerId => true]) : null); $providers = $this->providerList($tenantId, $userId, $providerId !== null ? new SourceSelector([$providerId => true]) : null);
foreach ($providers as $currentProviderId => $provider) { foreach ($providers as $currentProviderId => $provider) {
if (!($provider instanceof ProviderServiceDiscoverInterface)) { if ($provider instanceof ProviderServiceDiscoverInterface === false) {
continue; continue;
} }
@@ -399,7 +399,10 @@ class Manager {
if ($service === null) { if ($service === null) {
throw new InvalidArgumentException("Service not found: $providerId/$serviceId"); throw new InvalidArgumentException("Service not found: $providerId/$serviceId");
} }
if ($service->getEnabled() === false) {
throw new InvalidArgumentException("Service $providerId/$serviceId is disabled");
}
// test service
try { try {
return $provider->serviceTest($service); return $provider->serviceTest($service);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -471,6 +474,9 @@ class Manager {
$services = $provider->serviceList($tenantId, $userId, $serviceFilter); $services = $provider->serviceList($tenantId, $userId, $serviceFilter);
// retrieve collections for each service // retrieve collections for each service
foreach ($services as $service) { foreach ($services as $service) {
if ($service->getEnabled() === false) {
continue;
}
// construct filter for collections // construct filter for collections
$collectionFilter = null; $collectionFilter = null;
if ($filter !== null && $filter !== []) { if ($filter !== null && $filter !== []) {
@@ -518,26 +524,29 @@ class Manager {
// check services and collections for each available provider // check services and collections for each available provider
foreach ($providers as $provider) { foreach ($providers as $provider) {
// extract services for this provider
$serviceSelector = $sources[$provider->identifier()]; $serviceSelector = $sources[$provider->identifier()];
$servicesRequested = $serviceSelector->identifiers(); $servicesRequested = $serviceSelector->identifiers();
/** @var ServiceBaseInterface[] $servicesAvailable */ /** @var ServiceBaseInterface[] $servicesAvailable */
$servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested);
$servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable));
// mark unavailable services as false // mark unavailable services as false
if ($servicesUnavailable !== []) { if ($servicesUnavailable !== []) {
$responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false);
} }
// confirm collections for each available service // confirm collections for each available service
foreach ($servicesAvailable as $service) { foreach ($servicesAvailable as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
$responseData[$provider->identifier()][$service->identifier()] = false;
continue;
}
// extract collections requested for this service
$collectionSelector = $serviceSelector[$service->identifier()]; $collectionSelector = $serviceSelector[$service->identifier()];
$collectionsRequested = $collectionSelector->identifiers(); $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
if ($collectionsRequested === []) { if ($collectionsRequested === []) {
continue; continue;
} }
// check each requested collection // check each requested collection
$collectionsAvailable = $service->collectionExtant(...$collectionsRequested); $collectionsAvailable = $service->collectionExtant(...$collectionsRequested);
$collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable)); $collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable));
@@ -566,7 +575,7 @@ class Manager {
public function collectionFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId): ?CollectionBaseInterface { public function collectionFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId): ?CollectionBaseInterface {
// retrieve service // retrieve service
$service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId);
if ($service === null) { if ($service === null || $service->getEnabled() === false) {
return null; return null;
} }
// retrieve collection // retrieve collection
@@ -588,21 +597,22 @@ class Manager {
public function collectionCreate(string $tenantId, string $userId, string $provider, string|int $service, CollectionIdentifier|null $target, CollectionPropertiesMutableInterface|array $properties, array $options = []): CollectionBaseInterface { public function collectionCreate(string $tenantId, string $userId, string $provider, string|int $service, CollectionIdentifier|null $target, CollectionPropertiesMutableInterface|array $properties, array $options = []): CollectionBaseInterface {
// retrieve service // retrieve service
$service = $this->serviceFetch($tenantId, $userId, $provider, $service); $service = $this->serviceFetch($tenantId, $userId, $provider, $service);
// Check if service supports collection creation // Check if service supports collection creation
if (!($service instanceof ServiceCollectionMutableInterface)) { if ($service->getEnabled() === false) {
throw new InvalidArgumentException("Service does not support collection mutations"); throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled");
}
if ($service instanceof ServiceCollectionMutableInterface === false) {
throw new InvalidArgumentException("Service '{$service->identifier()}' does not support collection mutations");
} }
if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_CREATE)) { if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_CREATE)) {
throw new InvalidArgumentException("Service is not capable of creating collections"); throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of creating collections");
} }
// convert properties if necessary
if (is_array($properties)) { if (is_array($properties)) {
$collection = $service->collectionFresh()->getProperties()->jsonDeserialize($properties); $collection = $service->collectionFresh()->getProperties()->jsonDeserialize($properties);
} else { } else {
$collection = $properties; $collection = $properties;
} }
// Create collection // Create collection
return $service->collectionCreate($target, $collection, $options); return $service->collectionCreate($target, $collection, $options);
} }
@@ -621,21 +631,22 @@ class Manager {
public function collectionUpdate(string $tenantId, string $userId, CollectionIdentifier $target, CollectionPropertiesMutableInterface|array $properties): CollectionBaseInterface { public function collectionUpdate(string $tenantId, string $userId, CollectionIdentifier $target, CollectionPropertiesMutableInterface|array $properties): CollectionBaseInterface {
// retrieve service // retrieve service
$service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
// Check if service supports collection creation // Check if service supports collection creation
if (!($service instanceof ServiceCollectionMutableInterface)) { if ($service->getEnabled() === false) {
throw new InvalidArgumentException("Service does not support collection mutations"); throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled");
}
if ($service instanceof ServiceCollectionMutableInterface === false) {
throw new InvalidArgumentException("Service '{$service->identifier()}' does not support collection mutations");
} }
if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_UPDATE)) { if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_UPDATE)) {
throw new InvalidArgumentException("Service is not capable of updating collections"); throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of updating collections");
} }
// convert properties if necessary
if (is_array($properties)) { if (is_array($properties)) {
$mutation = $service->collectionFresh()->getProperties()->jsonDeserialize($properties); $mutation = $service->collectionFresh()->getProperties()->jsonDeserialize($properties);
} else { } else {
$mutation = $properties; $mutation = $properties;
} }
// Update collection // Update collection
return $service->collectionUpdate($target, $mutation); return $service->collectionUpdate($target, $mutation);
} }
@@ -655,17 +666,18 @@ class Manager {
public function collectionDelete(string $tenantId, ?string $userId, CollectionIdentifier $target, array $options = []): CollectionBaseInterface | bool { public function collectionDelete(string $tenantId, ?string $userId, CollectionIdentifier $target, array $options = []): CollectionBaseInterface | bool {
// retrieve service // retrieve service
$service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
// Check if service supports collection deletion // Check if service supports collection deletion
if (!($service instanceof ServiceCollectionMutableInterface)) { if ($service->getEnabled() === false) {
throw new InvalidArgumentException("Service does not support collection mutations"); throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled");
}
if ($service instanceof ServiceCollectionMutableInterface === false) {
throw new InvalidArgumentException("Service '{$service->identifier()}' does not support collection mutations");
} }
if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_DELETE)) { if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_DELETE)) {
throw new InvalidArgumentException("Service is not capable of deleting collections"); throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of deleting collections");
} }
// convert options
$force = $options['force'] ?? false; $force = $options['force'] ?? false;
// delete collection // delete collection
return $service->collectionDelete($target, $force); return $service->collectionDelete($target, $force);
} }
@@ -685,23 +697,24 @@ class Manager {
public function collectionMove(string $tenantId, ?string $userId, CollectionIdentifier $target, CollectionIdentifier $source): CollectionBaseInterface { public function collectionMove(string $tenantId, ?string $userId, CollectionIdentifier $target, CollectionIdentifier $source): CollectionBaseInterface {
// validate that source and target are the same provider and service // validate that source and target are the same provider and service
if ($source->provider() !== $target->provider() || $source->service() !== $target->service()) { if ($source->provider() !== $target->provider() || $source->service() !== $target->service()) {
throw new InvalidArgumentException("Source and target collections must belong to the same provider and service"); throw new InvalidArgumentException("Source '{$source->service()}' and target '{$target->service()}' collections must belong to the same provider and service");
} }
// Validate that source and target are not the same // Validate that source and target are not the same
if ($source->collection() === $target->collection()) { if ($source->collection() === $target->collection()) {
throw new InvalidArgumentException("Source and target collections are the same"); throw new InvalidArgumentException("Source '{$source->collection()}' and target '{$target->collection()}' collections are the same");
} }
// retrieve service // retrieve service
$service = $this->serviceFetch($tenantId, $userId, $source->provider(), $source->service()); $service = $this->serviceFetch($tenantId, $userId, $source->provider(), $source->service());
// Check if service supports collection move // Check if service supports collection move
if (!($service instanceof ServiceCollectionMutableInterface)) { if ($service->getEnabled() === false) {
throw new InvalidArgumentException("Service does not support collection mutations"); throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled");
}
if ($service instanceof ServiceCollectionMutableInterface === false) {
throw new InvalidArgumentException("Service '{$service->identifier()}' does not support collection mutations");
} }
if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_MOVE)) { if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_MOVE)) {
throw new InvalidArgumentException("Service is not capable of moving collections"); throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of moving collections");
} }
// move collection // move collection
return $service->collectionMove($target, $source); return $service->collectionMove($target, $source);
} }
@@ -733,6 +746,10 @@ class Manager {
$servicesSelected = $provider->serviceList($tenantId,$userId, $serviceSelector->identifiers()); $servicesSelected = $provider->serviceList($tenantId,$userId, $serviceSelector->identifiers());
/** @var ServiceBaseInterface $service */ /** @var ServiceBaseInterface $service */
foreach ($servicesSelected as $service) { foreach ($servicesSelected as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
continue;
}
// retrieve collections for each service // retrieve collections for each service
$collectionSelector = $serviceSelector[$service->identifier()]; $collectionSelector = $serviceSelector[$service->identifier()];
$collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
@@ -818,6 +835,10 @@ class Manager {
$servicesSelected = $provider->serviceList($tenantId, $userId, $serviceSelector->identifiers()); $servicesSelected = $provider->serviceList($tenantId, $userId, $serviceSelector->identifiers());
/** @var ServiceBaseInterface $service */ /** @var ServiceBaseInterface $service */
foreach ($servicesSelected as $service) { foreach ($servicesSelected as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
continue;
}
// retrieve collections for each service // retrieve collections for each service
$collectionSelector = $serviceSelector[$service->identifier()]; $collectionSelector = $serviceSelector[$service->identifier()];
$collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
@@ -888,7 +909,9 @@ class Manager {
*/ */
public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array { public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array {
$service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId);
if ($service->getEnabled() === false) {
throw new InvalidArgumentException("Service '{$providerId}:{$serviceId}' not found or is disabled");
}
// retrieve collection // retrieve collection
return $service->entityFetch($collectionId, ...$identifiers); return $service->entityFetch($collectionId, ...$identifiers);
} }
@@ -913,46 +936,44 @@ class Manager {
$providers = $this->providerList($tenantId, $userId, $sources); $providers = $this->providerList($tenantId, $userId, $sources);
$providersRequested = $sources->identifiers(); $providersRequested = $sources->identifiers();
$providersUnavailable = array_diff($providersRequested, array_keys($providers)); $providersUnavailable = array_diff($providersRequested, array_keys($providers));
// initialize response with unavailable providers // initialize response with unavailable providers
$responseData = array_fill_keys($providersUnavailable, false); $responseData = array_fill_keys($providersUnavailable, false);
// check services, collections, and entities for each available provider // check services, collections, and entities for each available provider
foreach ($providers as $provider) { foreach ($providers as $provider) {
// extract services requested for this provider
$serviceSelector = $sources[$provider->identifier()]; $serviceSelector = $sources[$provider->identifier()];
$servicesRequested = $serviceSelector->identifiers(); $servicesRequested = $serviceSelector->identifiers();
/** @var ServiceBaseInterface[] $servicesAvailable */ /** @var ServiceBaseInterface[] $servicesAvailable */
$servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested);
$servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable));
// mark unavailable services as false // mark unavailable services as false
if ($servicesUnavailable !== []) { if ($servicesUnavailable !== []) {
$responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false);
} }
// check collections and entities for each available service // check collections and entities for each available service
foreach ($servicesAvailable as $service) { foreach ($servicesAvailable as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
$responseData[$provider->identifier()][$service->identifier()] = false;
continue;
}
// extract collections requested for this service
$collectionSelector = $serviceSelector[$service->identifier()]; $collectionSelector = $serviceSelector[$service->identifier()];
$collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
if ($collectionsRequested === []) { if ($collectionsRequested === []) {
continue; continue;
} }
// check entities for each requested collection // check entities for each requested collection
foreach ($collectionsRequested as $collectionId) { foreach ($collectionsRequested as $collectionId) {
// first check if collection exists // first check if collection exists
$collectionExists = $service->collectionExtant((string)$collectionId); $collectionExists = $service->collectionExtant((string)$collectionId);
if (!$collectionExists) { if (!$collectionExists) {
// collection doesn't exist, mark as false // collection doesn't exist, mark as false
$responseData[$provider->identifier()][$service->identifier()][$collectionId] = false; $responseData[$provider->identifier()][$service->identifier()][$collectionId] = false;
continue; continue;
} }
// extract entity identifiers from collection selector // extract entity identifiers from collection selector
$entitySelector = $collectionSelector[$collectionId]; $entitySelector = $collectionSelector[$collectionId];
// handle both array of entity IDs and boolean true (meaning check if collection exists) // handle both array of entity IDs and boolean true (meaning check if collection exists)
if ($entitySelector instanceof EntitySelector) { if ($entitySelector instanceof EntitySelector) {
// check specific entities within the collection // check specific entities within the collection
@@ -991,6 +1012,7 @@ class Manager {
$responseData = array_fill_keys($providersUnavailable, false); $responseData = array_fill_keys($providersUnavailable, false);
// iterate through available providers // iterate through available providers
foreach ($providers as $provider) { foreach ($providers as $provider) {
// extract services requested for this provider
$serviceSelector = $sources[$provider->identifier()]; $serviceSelector = $sources[$provider->identifier()];
$servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; $servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : [];
/** @var ServiceBaseInterface[] $services */ /** @var ServiceBaseInterface[] $services */
@@ -1001,12 +1023,19 @@ class Manager {
} }
// iterate through available services // iterate through available services
foreach ($services as $service) { foreach ($services as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
$responseData[$provider->identifier()][$service->identifier()] = false;
continue;
}
// extract collections requested for this service
$collectionSelector = $serviceSelector[$service->identifier()]; $collectionSelector = $serviceSelector[$service->identifier()];
$collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
if ($collectionsRequested === []) { if ($collectionsRequested === []) {
$responseData[$provider->identifier()][$service->identifier()] = false; $responseData[$provider->identifier()][$service->identifier()] = false;
continue; continue;
} }
// check delta for each requested collection
foreach ($collectionsRequested as $collection) { foreach ($collectionsRequested as $collection) {
$entitySelector = $collectionSelector[$collection] ?? null; $entitySelector = $collectionSelector[$collection] ?? null;
$responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector); $responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector);
@@ -1017,61 +1046,72 @@ class Manager {
} }
/** /**
* Deletes entities * Patches entities
* *
* @since 2026.04.01 * @since 2026.04.01
* *
* @param string $tenantId Tenant identifier * @param string $tenantId Tenant identifier
* @param string|null $userId User identifier for context * @param string|null $userId User identifier for context
* @param ResourceIdentifiers $sources Source entities to delete * @param MessagePropertiesMutableInterface|array $properties Properties to patch
* @param EntityIdentifier ...$targets Target entities to patch
* *
* @return array<string, array{ * @return array<string, array{
* disposition: 'moved'|'deleted'|'error', * disposition: 'patched'|'error',
* destination: ?CollectionIdentifier, * error?: string
* mutation: EntityIdentifier
* }> Results keyed by source entity identifier * }> Results keyed by source entity identifier
*/ */
public function entityDelete(string $tenantId, string $userId, ResourceIdentifiers $sources): array { public function entityPatch(string $tenantId, string $userId, MessagePropertiesMutableInterface|array $properties, EntityIdentifier ...$targets): array {
$operationOutcome = []; $operationOutcome = [];
$targetIdentifiers = new ResourceIdentifiers();
foreach ($sources->providers() as $providerName) { foreach ($targets as $target) {
$providerSources = $sources->byProvider($providerName); $targetIdentifiers->add($target);
foreach ($providerSources->services() as $serviceName) { }
$serviceSources = $providerSources->byService($serviceName);
// process targets grouped by provider
foreach ($targetIdentifiers->providers() as $providerId) {
// retrieve provider and validate
$provider = $this->providerFetch($tenantId, $userId, $providerId);
$providerTargets = $targetIdentifiers->byProvider($providerId);
// provider not found, mark all identifiers as failed and continue to next provider
if ($provider === null) {
foreach ($providerTargets->services() as $serviceId) {
$serviceTargets = $providerTargets->byService($serviceId);
foreach ($serviceTargets as $identifier) {
$operationOutcome[$identifier] = ['disposition' => 'error', 'error' => 'provider not found'];
}
}
continue;
}
// process targets grouped by service for this provider
foreach ($providerTargets->services() as $serviceId) {
// extract services requested for this provider
$serviceTargets = $providerTargets->byService($serviceId);
// retrieve and validate service
$service = null; $service = null;
try { try {
$service = $this->serviceFetch($tenantId, $userId, $providerName, $serviceName); $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Service not found, mark all identifiers as failed $error = "Service $serviceId not found";
} }
if ($service instanceof ServiceEntityMutableInterface === false && !isset($error)) {
if ($service === null) { $error = "Service $serviceId does not support entity mutation";
throw new InvalidArgumentException("Service not found: $providerName/$serviceName");
} }
if (!isset($error) && !$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_PATCH)) {
// Temporarily disabled check until all methods are properly implemented from ServiceEntityMutableInterface $error = "Service $serviceId does not support entity patching";
if (!($service instanceof ServiceEntityMutableInterface)) { }
foreach ($serviceSources as $identifier) { // on error, mark all identifiers for this service as failed and continue to next service
$operationOutcome[(string)$identifier] = [ if (isset($error)) {
'success' => false, foreach ($serviceTargets as $identifier) {
'error' => 'serviceNotEntityMutable', $operationOutcome[(string)$identifier] = ['disposition' => 'error','error' => $error];
];
} }
continue; continue;
} }
/** @var ServiceEntityMutableInterface $service */
if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_DELETE)) { if ($properties instanceof MessagePropertiesMutableInterface === false) {
foreach ($serviceSources as $identifier) { $properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties);
$operationOutcome[(string)$identifier] = [
'success' => false,
'error' => 'serviceCannotDeleteEntities',
];
}
continue;
} }
$operationOutcome = array_merge($operationOutcome, $service->entityPatch($properties, ...$serviceTargets->all()));
return $service->entityDelete(...$serviceSources->all());
} }
} }
@@ -1079,67 +1119,69 @@ class Manager {
} }
/** /**
* Patches entities * Deletes entities
* *
* @since 2026.04.01 * @since 2026.04.01
* *
* @param string $tenantId Tenant identifier * @param string $tenantId Tenant identifier
* @param string|null $userId User identifier for context * @param string|null $userId User identifier for context
* @param ResourceIdentifiers $sources Source entities to patch * @param EntityIdentifier $targets Source entities to delete
* @param array $patchData Data to patch
* *
* @return array<string, array{ * @return array<string, array{
* disposition: 'patched'|'error', * disposition: 'moved'|'deleted'|'error',
* error?: string * destination: ?CollectionIdentifier,
* mutation: EntityIdentifier
* }> Results keyed by source entity identifier * }> Results keyed by source entity identifier
*/ */
public function entityPatch(string $tenantId, string $userId, ResourceIdentifiers $targets, array $patch): array { public function entityDelete(string $tenantId, string $userId, EntityIdentifier ...$targets): array {
$operationOutcome = []; $operationOutcome = [];
$targetIdentifiers = new ResourceIdentifiers();
// Process targets grouped by provider and service to minimize redundant fetches and operations foreach ($targets as $target) {
foreach ($targets->providers() as $providerName) { $targetIdentifiers->add($target);
$providerTargets = $targets->byProvider($providerName); }
$provider = $this->providerFetch($tenantId, $userId, $providerName);
// If provider is not found, mark all identifiers as failed and continue to next provider // process targets grouped by provider
foreach ($targetIdentifiers->providers() as $providerId) {
// retrieve provider and validate
$provider = $this->providerFetch($tenantId, $userId, $providerId);
$providerTargets = $targetIdentifiers->byProvider($providerId);
// provider not found, mark all identifiers as failed and continue to next provider
if ($provider === null) { if ($provider === null) {
foreach ($providerTargets->services() as $serviceName) { foreach ($providerTargets->services() as $serviceId) {
$serviceTargets = $providerTargets->byService($serviceName); $serviceTargets = $providerTargets->byService($serviceId);
foreach ($serviceTargets as $identifier) { foreach ($serviceTargets as $identifier) {
$operationOutcome[$identifier] = ['disposition' => 'error', 'error' => 'provider not found']; $operationOutcome[$identifier] = ['disposition' => 'error', 'error' => 'provider not found'];
} }
} }
continue; continue;
} }
// process targets grouped by service for this provider
foreach ($providerTargets->services() as $serviceName) { foreach ($providerTargets->services() as $serviceId) {
$serviceTargets = $providerTargets->byService($serviceName); // extract services requested for this provider
$service = $this->serviceFetch($tenantId, $userId, $providerName, $serviceName); $serviceTargets = $providerTargets->byService($serviceId);
// If service is not found, mark all identifiers as failed and continue to next service // retrieve and validate service
if ($service === null) { $service = null;
try {
$service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId);
} catch (\Throwable $e) {
$error = "Service $serviceId not found";
}
if ($service instanceof ServiceEntityMutableInterface === false && !isset($error)) {
$error = "Service $serviceId does not support entity mutation";
}
if (!isset($error) && !$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_DELETE)) {
$error = "Service $serviceId does not support entity deletion";
}
// on error, mark all identifiers for this service as failed and continue to next service
if (isset($error)) {
foreach ($serviceTargets as $identifier) { foreach ($serviceTargets as $identifier) {
$operationOutcome[$identifier] = ['disposition' => 'error', 'error' => 'service not found']; $operationOutcome[(string)$identifier] = ['disposition' => 'error', 'error' => $error];
} }
continue; continue;
} }
// If service does not support entity mutation, mark all identifiers as failed and continue to next service /** @var ServiceEntityMutableInterface $service */
if (!($service instanceof ServiceEntityMutableInterface)) { $operationOutcome = array_merge($operationOutcome, $service->entityDelete(...$serviceTargets->all()));
foreach ($serviceTargets as $identifier) {
$operationOutcome[$identifier] = ['disposition' => 'error', 'error' => 'entity mutations not supported'];
}
continue;
}
// If service does not support entity patching, mark all identifiers as failed and continue to next service
if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_PATCH)) {
foreach ($serviceTargets as $identifier) {
$operationOutcome[$identifier] = ['disposition' => 'error', 'error' => 'entity patching not supported'];
}
continue;
}
$properties = $service->entityFresh()->getProperties()->jsonDeserialize($patch);
return $service->entityPatch(...$serviceTargets->all(), patch: $patch);
} }
} }
@@ -1154,7 +1196,7 @@ class Manager {
* @param string $tenantId Tenant identifier * @param string $tenantId Tenant identifier
* @param string|null $userId User identifier for context * @param string|null $userId User identifier for context
* @param CollectionIdentifier $target Target collection identifier * @param CollectionIdentifier $target Target collection identifier
* @param ResourceIdentifiers $sources Source entities to move * @param EntityIdentifier ...$sources Source entities to move
* *
* @return array<string, array{ * @return array<string, array{
* disposition: 'moved'|'error', * disposition: 'moved'|'error',
@@ -1162,24 +1204,54 @@ class Manager {
* mutation: EntityIdentifier * mutation: EntityIdentifier
* }> Results keyed by source entity identifier * }> Results keyed by source entity identifier
*/ */
public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, ResourceIdentifiers $sources): array { public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, EntityIdentifier ...$sources): array {
$targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
// Check if service supports entity move
if ($targetService instanceof ServiceEntityMutableInterface === false) {
return [];
}
if (!$targetService->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_MOVE)) {
return [];
}
$operationOutcome = []; $operationOutcome = [];
// retrieve and validate service
$destinationSources = $sources->byProvider($targetService->provider())->byService((string)$targetService->identifier()); $targetService = null;
if (!$destinationSources->isEmpty()) { try {
$operationOutcome = $targetService->entityMove($target, ...$destinationSources->all()); $targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
} catch (\Throwable $e) {
// do nothing here, error will be handled in validation below
} }
if ($targetService === null || $targetService->getEnabled() === false) {
$error = "Service {$target->service()} not found or is disabled";
}
/** @var ServiceEntityBaseInterface $targetService */
if ($targetService instanceof ServiceEntityMutableInterface === false && !isset($error)) {
$error = "Service {$targetService->identifier()} does not support entity mutation";
}
if (!isset($error) && !$targetService->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_MOVE)) {
$error = "Service {$targetService->identifier()} does not support entity moving";
}
// on error, mark all identifiers for this service as failed and continue to next service
if (isset($error)) {
foreach ($sources as $identifier) {
$operationOutcome[(string)$identifier] = ['disposition' => 'error','error' => $error];
}
return $operationOutcome;
}
// validate that sources and target are the same service and group sources by service for processing
$groupedSources = [];
foreach ($sources as $source) {
if ($source->provider() !== $target->provider() || $source->service() !== $target->service()) {
$operationOutcome[(string)$source] = [
'disposition' => 'error',
'error' => "Source '{$source}' and target '{$target}' collections must belong to the same provider and service"
];
continue;
}
$groupedSources[$source->provider()][$source->service()][] = $source;
}
if ($groupedSources === []) {
return $operationOutcome;
}
// perform operation for entities on the same service as the target
$operationOutcome = array_merge(
$operationOutcome,
$targetService->entityMove($target, ...$groupedSources[$target->provider()][$target->service()])
);
// TODO: Handle moving entities across different services/providers by fetching each entity and re-creating it in the target collection, // TODO: Handle moving entities across different services/providers by fetching each entity and re-creating it in the target collection,
// then deleting the original if the move is successful. This will require additional logic to handle potential failures and ensure data integrity. // then deleting the original if the move is successful. This will require additional logic to handle potential failures and ensure data integrity.
@@ -1187,4 +1259,5 @@ class Manager {
return $operationOutcome; return $operationOutcome;
} }
} }

View File

@@ -203,6 +203,7 @@ function handleNextStep() {
async function handleDiscover() { async function handleDiscover() {
// Move to discovery status screen // Move to discovery status screen
isManualMode.value = false
currentStep.value = DISCOVERY_STEPS.DISCOVERY currentStep.value = DISCOVERY_STEPS.DISCOVERY
// Extract provider IDs // Extract provider IDs