From 1c918ca55c3bc01dfc7028adb86bce0dd3fcce4c Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 28 Mar 2026 09:32:04 -0400 Subject: [PATCH] feat: entity move Signed-off-by: Sebastian Krupinski --- lib/Controllers/DefaultController.php | 39 +++++++++++- lib/Manager.php | 55 +++++++++++++++++ src/services/entityService.ts | 13 ++++ src/stores/entitiesStore.ts | 85 ++++++++++++++++++++++++++- src/types/common.ts | 4 ++ src/types/entity.ts | 26 +++++++- 6 files changed, 216 insertions(+), 6 deletions(-) diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 7d7862e..386dd52 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -17,6 +17,10 @@ use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; use KTXF\Json\JsonSerializable; +use KTXF\Resource\Identifier\CollectionIdentifier; +use KTXF\Resource\Identifier\EntityIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Selector\SourceSelector; use KTXF\Routing\Attributes\AuthenticatedRoute; @@ -38,6 +42,7 @@ class DefaultController extends ControllerAbstract { private const ERR_MISSING_COLLECTION = 'Missing parameter: collection'; private const ERR_MISSING_DATA = 'Missing parameter: data'; private const ERR_MISSING_SOURCES = 'Missing parameter: sources'; + private const ERR_MISSING_TARGET = 'Missing parameter: target'; private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers'; private const ERR_INVALID_OPERATION = 'Invalid operation: '; private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string'; @@ -45,6 +50,7 @@ class DefaultController extends ControllerAbstract { private const ERR_INVALID_IDENTIFIER = 'Invalid parameter: identifier must be a string'; private const ERR_INVALID_COLLECTION = 'Invalid parameter: collection must be a string or integer'; private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array'; + private const ERR_INVALID_TARGET = 'Invalid parameter: target must be an array'; private const ERR_INVALID_IDENTIFIERS = 'Invalid parameter: identifiers must be an array'; private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array'; @@ -157,7 +163,7 @@ class DefaultController extends ControllerAbstract { 'entity.delete' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.stream' => $this->entityStream($tenantId, $userId, $data, $version, $transaction), 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), - 'entity.move' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), + 'entity.move' => $this->entityMove($tenantId, $userId, $data), 'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data), @@ -634,6 +640,35 @@ class DefaultController extends ControllerAbstract { return $this->mailManager->entityDelta($tenantId, $userId, $sources); } + private function entityMove(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); + } + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); + } + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $target = ResourceIdentifier::fromString($data['target']); + if (!$target instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } + + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers'); + } + } + + return $this->mailManager->entityMove($tenantId, $userId, $target, $sources); + } + private function entityTransmit(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); @@ -659,8 +694,6 @@ class DefaultController extends ControllerAbstract { return ['jobId' => $jobId]; } - // ==================== Entity Stream ==================== - private function entityStream(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse { if (!isset($data['sources'])) { throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); diff --git a/lib/Manager.php b/lib/Manager.php index b959603..b739f24 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -21,7 +21,12 @@ use KTXF\Mail\Queue\SendOptions; use KTXF\Mail\Service\IServiceSend; use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceCollectionMutableInterface; +use KTXF\Mail\Service\ServiceEntityMutableInterface; use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Identifier\CollectionIdentifier; +use KTXF\Resource\Identifier\EntityIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Range\IRange; @@ -976,6 +981,56 @@ class Manager { return $responseData; } + public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, ResourceIdentifiers $sources): array { + + $targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); + + // Check if service supports entity move + if ($targetService instanceof ServiceEntityMutableInterface === false) { + //return []; + } + + $operationOutcome = []; + + $destinationSources = $sources->byProvider($targetService->provider())->byService((string)$targetService->identifier()); + if (!$destinationSources->isEmpty()) { + $entitiesToMove = []; + foreach ($destinationSources as $identifier) { + $entitiesToMove[$identifier->collection()][] = $identifier->entity(); + } + $operationResult = $targetService->entityMove($target->collection(), $entitiesToMove); + + foreach ($destinationSources as $identifier) { + $sourceIdentifier = (string)$identifier; + $entityIdentifier = $identifier->entity(); + $result = $operationResult[$entityIdentifier] ?? null; + + if ($result === true) { + $operationOutcome[$sourceIdentifier] = [ + 'success' => true, + 'identifier' => (string)new EntityIdentifier( + $target->provider(), + $target->service(), + $target->collection(), + $entityIdentifier, + ), + ]; + continue; + } + + $operationOutcome[$sourceIdentifier] = [ + 'success' => false, + 'error' => is_string($result) && $result !== '' ? $result : 'unknownError', + ]; + } + } + + // 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. + + return $operationOutcome; + } + /** * Send a mail message * diff --git a/src/services/entityService.ts b/src/services/entityService.ts index 32dc923..19dca1b 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -18,6 +18,8 @@ import type { EntityDeleteResponse, EntityDeltaRequest, EntityDeltaResponse, + EntityMoveRequest, + EntityMoveResponse, EntityTransmitRequest, EntityTransmitResponse, EntityInterface, @@ -149,6 +151,17 @@ export const entityService = { return await transceivePost('entity.delta', request); }, + /** + * Move entities to a target collection + * + * @param request - move request parameters + * + * @returns Promise with move results keyed by source entity identifier + */ + async move(request: EntityMoveRequest): Promise { + return await transceivePost('entity.move', request); + }, + /** * Send an entity * diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts index 323dcb4..0a2384f 100644 --- a/src/stores/entitiesStore.ts +++ b/src/stores/entitiesStore.ts @@ -6,8 +6,20 @@ import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' import { entityService } from '../services' import { EntityObject } from '../models' -import type { EntityTransmitRequest, EntityTransmitResponse, EntityStreamRequest } from '../types/entity' -import type { SourceSelector, ListFilter, ListSort, ListRange } from '../types/common' +import type { + EntityMoveResponse, + EntityStreamRequest, + EntityTransmitRequest, + EntityTransmitResponse, +} from '../types/entity' +import type { + CollectionIdentifier, + EntityIdentifier, + ListFilter, + ListRange, + ListSort, + SourceSelector, +} from '../types/common' export const useEntitiesStore = defineStore('mailEntitiesStore', () => { // State @@ -88,6 +100,24 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { return `${provider}:${service}:${collection}:${identifier}` } + /** + * Parse a full entity identifier into its components. + */ + function parseEntityIdentifier(identifier: EntityIdentifier): { + provider: string + service: string + collection: string + identifier: string + } { + const [provider, service, collection, entity] = identifier.split(':', 4) + return { + provider, + service, + collection, + identifier: entity, + } + } + // Actions /** @@ -315,6 +345,56 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { } } + /** + * Move entities to another collection. + * + * Updates local store keys for successfully moved entities when they are + * already present in cache. + * + * @param target - target collection identifier + * @param sources - source entity identifiers + * + * @returns Promise with move results keyed by source identifier + */ + async function move(target: CollectionIdentifier, sources: EntityIdentifier[]): Promise { + transceiving.value = true + try { + const response = await entityService.move({ target, sources }) + + Object.entries(response).forEach(([sourceIdentifier, result]) => { + if (!result.success) { + return + } + + const cachedEntity = _entities.value[sourceIdentifier] + if (!cachedEntity) { + return + } + + const destination = parseEntityIdentifier(result.identifier) + const movedEntity = cachedEntity.clone().fromJson({ + ...cachedEntity.toJson(), + provider: destination.provider, + service: destination.service, + collection: destination.collection, + identifier: destination.identifier, + }) + + delete _entities.value[sourceIdentifier] + + _entities.value[result.identifier] = movedEntity + }) + + console.debug('[Mail Manager][Store] - Successfully moved', Object.keys(response).length, 'entities') + return response + } catch (error: any) { + console.error('[Mail Manager][Store] - Failed to move entities:', error) + throw error + } finally { + transceiving.value = false + } + } + /** * Send/transmit an entity * @@ -390,6 +470,7 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => { update, delete: remove, delta, + move, transmit, stream, } diff --git a/src/types/common.ts b/src/types/common.ts index e236f47..69f697f 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -113,6 +113,10 @@ export type CollectionSelector = { export type EntitySelector = (string | number)[]; +export type ProviderIdentifier = `${string}`; +export type ServiceIdentifier = `${string}:${string}`; +export type CollectionIdentifier = `${string}:${string}:${string}`; +export type EntityIdentifier = `${string}:${string}:${string}:${string}`; /** * Filter comparison for list operations diff --git a/src/types/entity.ts b/src/types/entity.ts index 8b299c7..9bcb40f 100644 --- a/src/types/entity.ts +++ b/src/types/entity.ts @@ -2,10 +2,12 @@ * Entity type definitions */ import type { + CollectionIdentifier, + EntityIdentifier, + SourceSelector, ListFilter, ListRange, ListSort, - SourceSelector, } from './common'; import type { MessageInterface } from './message'; @@ -133,6 +135,28 @@ export interface EntityDeltaResponse { }; } +/** + * Entity move + */ +export interface EntityMoveRequest { + target: CollectionIdentifier; + sources: EntityIdentifier[]; +} + +export interface EntityMoveResultSuccess { + success: boolean; + identifier: EntityIdentifier; +} + +export interface EntityMoveResultFailure { + success: boolean; + error: string; +} + +export interface EntityMoveResponse { + [sourceIdentifier: EntityIdentifier]: EntityMoveResultSuccess | EntityMoveResultFailure; +} + /** * Entity transmit */