feat: entity move #12

Merged
Sebastian merged 1 commits from feat/entity-move into main 2026-03-28 16:37:14 +00:00
6 changed files with 216 additions and 6 deletions
Showing only changes of commit 1c918ca55c - Show all commits

View File

@@ -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);

View File

@@ -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
*

View File

@@ -18,6 +18,8 @@ import type {
EntityDeleteResponse,
EntityDeltaRequest,
EntityDeltaResponse,
EntityMoveRequest,
EntityMoveResponse,
EntityTransmitRequest,
EntityTransmitResponse,
EntityInterface,
@@ -149,6 +151,17 @@ export const entityService = {
return await transceivePost<EntityDeltaRequest, EntityDeltaResponse>('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<EntityMoveResponse> {
return await transceivePost<EntityMoveRequest, EntityMoveResponse>('entity.move', request);
},
/**
* Send an entity
*

View File

@@ -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<EntityMoveResponse> {
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,
}

View File

@@ -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

View File

@@ -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
*/