diff --git a/lib/Controllers/CollectionController.php b/lib/Controllers/CollectionController.php deleted file mode 100644 index 5d0b223..0000000 --- a/lib/Controllers/CollectionController.php +++ /dev/null @@ -1,178 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ChronoManager\Controllers; - -use KTXC\Http\Response\JsonResponse; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; -use KTXF\Controller\ControllerAbstract; -use KTXF\Chrono\Collection\ICollectionBase; -use KTXF\Routing\Attributes\AuthenticatedRoute; -use InvalidArgumentException; -use KTXF\Resource\Selector\SourceSelector; -use KTXM\ChronoManager\Manager; - -class CollectionController extends ControllerAbstract { - - public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, - private Manager $chronoManager, - ) {} - - /** - * Retrieve all collections matching criteria - * - * @param SourceSelector|null $sources collection sources - * @param array|null $filter collection filter options - * @param array|null $sort collection sorting options - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/collection/list', name: 'chronomanager.collection.list', methods: ['POST'])] - - public function list(?SourceSelector $sources = null, ?array $filter = null, ?array $sort = null, ?string $uid = null): JsonResponse { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // retrieve collections - $responseData = $this->chronoManager->collectionList($tenantId, $userId, $sources, $filter, $sort); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } - - /** - * Confirm if specific collections are available for a specific user - * - * @param SourceSelector $sources collection sources - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/collection/extant', name: 'chronomanager.collection.extant', methods: ['POST'])] - - public function extant(SourceSelector $sources, ?string $uid = null): JsonResponse { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // retrieve collection status - $responseData = $this->chronoManager->collectionExtant($tenantId, $userId, $sources); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } - - /** - * Fetch a collection - * - * @param string $provider provider identifier - * @param string $service service identifier - * @param string|int $identifier collection identifier - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/collection/fetch', name: 'chronomanager.collection.fetch', methods: ['POST'])] - - public function fetch(string $provider, string $service, string|int $identifier, ?string $uid = null): JsonResponse { - try { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // retrieve collection - $responseData = $this->chronoManager->collectionFetch($tenantId, $userId, $provider, $service, $identifier); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } catch (InvalidArgumentException $e) { - return new JsonResponse(['error' => $e->getMessage()], JsonResponse::HTTP_BAD_REQUEST); - } - } - - /** - * Create a collection - * - * @param string $provider provider identifier - * @param string $service service identifier - * @param ICollectionBase|array $data collection to create - * @param array $options additional options - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/collection/create', name: 'chronomanager.collection.create', methods: ['POST'])] - - public function create(string $provider, string $service, ICollectionBase|array $data, array $options = [], ?string $uid = null): JsonResponse { - try { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // create collection - $responseData = $this->chronoManager->collectionCreate($tenantId, $userId, $provider, $service, $data, $options); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } catch (InvalidArgumentException $e) { - return new JsonResponse(['error' => $e->getMessage()], JsonResponse::HTTP_BAD_REQUEST); - } - } - - /** - * Modify an existing collection - * - * @param string $provider provider identifier - * @param string $service service identifier - * @param string|int $identifier collection identifier - * @param ICollectionBase|array $data collection data - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/collection/modify', name: 'chronomanager.collection.modify', methods: ['POST'])] - - public function modify(string $provider, string $service, string|int $identifier, ICollectionBase|array $data, ?string $uid = null): JsonResponse { - try { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // modify collection - $responseData = $this->chronoManager->collectionModify($tenantId, $userId, $provider, $service, $identifier, $data); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } catch (InvalidArgumentException $e) { - return new JsonResponse(['error' => $e->getMessage()], JsonResponse::HTTP_BAD_REQUEST); - } - } - - /** - * Delete a collection - * - * @param string $provider provider identifier - * @param string $service service identifier - * @param string|int $identifier collection identifier - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/collection/destroy', name: 'chronomanager.collection.destroy', methods: ['POST'])] - - public function destroy(string $provider, string $service, string|int $identifier, string|null $uid = null): JsonResponse { - try { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // destroy collection - $success = $this->chronoManager->collectionDestroy($tenantId, $userId, $provider, $service, $identifier); - - return new JsonResponse(['success' => $success], $success ? JsonResponse::HTTP_OK : JsonResponse::HTTP_NOT_FOUND); - } catch (InvalidArgumentException $e) { - return new JsonResponse(['error' => $e->getMessage()], JsonResponse::HTTP_BAD_REQUEST); - } - } - -} diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php new file mode 100644 index 0000000..36885ad --- /dev/null +++ b/lib/Controllers/DefaultController.php @@ -0,0 +1,635 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ChronoManager\Controllers; + +use InvalidArgumentException; +use KTXC\Http\Response\JsonResponse; +use KTXC\SessionIdentity; +use KTXC\SessionTenant; +use KTXF\Controller\ControllerAbstract; +use KTXF\Resource\Selector\SourceSelector; +use KTXF\Routing\Attributes\AuthenticatedRoute; +use KTXM\ChronoManager\Manager; +use Psr\Log\LoggerInterface; +use Throwable; + +class DefaultController extends ControllerAbstract { + + private const ERR_MISSING_PROVIDER = 'Missing parameter: provider'; + private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier'; + private const ERR_MISSING_SERVICE = 'Missing parameter: service'; + 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_IDENTIFIERS = 'Missing parameter: identifiers'; + private const ERR_INVALID_OPERATION = 'Invalid operation: '; + private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string'; + private const ERR_INVALID_SERVICE = 'Invalid parameter: service must be a string'; + 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_IDENTIFIERS = 'Invalid parameter: identifiers must be an array'; + private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array'; + + public function __construct( + private readonly SessionTenant $tenantIdentity, + private readonly SessionIdentity $userIdentity, + private Manager $chronoManager, + private readonly LoggerInterface $logger, + ) {} + + /** + * Main API endpoint for mail operations + * + * Single operation: + * { + * "version": 1, + * "transaction": "tx-1", + * "operation": "entity.create", + * "data": {...} + * } + * + * @return JsonResponse + */ + #[AuthenticatedRoute('/v1', name: 'chrono.manager.v1', methods: ['POST'])] + public function index( + int $version, + string $transaction, + string|null $operation = null, + array|null $data = null, + string|null $user = null + ): JsonResponse { + + // authorize request + $tenantId = $this->tenantIdentity->identifier(); + $userId = $this->userIdentity->identifier(); + + try { + + if ($operation !== null) { + $result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], []); + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'success', + 'data' => $result + ], JsonResponse::HTTP_OK); + } + + throw new InvalidArgumentException('Operation must be provided'); + + } catch (Throwable $t) { + $this->logger->error('Error processing request', ['exception' => $t]); + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => [ + 'code' => $t->getCode(), + 'message' => $t->getMessage() + ] + ], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Process a single operation + */ + private function processOperation(string $tenantId, string $userId, string $operation, array $data): mixed { + return match ($operation) { + // Provider operations + 'provider.list' => $this->providerList($tenantId, $userId, $data), + 'provider.fetch' => $this->providerFetch($tenantId, $userId, $data), + 'provider.extant' => $this->providerExtant($tenantId, $userId, $data), + + // Service operations + 'service.list' => $this->serviceList($tenantId, $userId, $data), + 'service.fetch' => $this->serviceFetch($tenantId, $userId, $data), + 'service.extant' => $this->serviceExtant($tenantId, $userId, $data), + 'service.create' => $this->serviceCreate($tenantId, $userId, $data), + 'service.update' => $this->serviceUpdate($tenantId, $userId, $data), + 'service.delete' => $this->serviceDelete($tenantId, $userId, $data), + 'service.test' => $this->serviceTest($tenantId, $userId, $data), + + // Collection operations + 'collection.list' => $this->collectionList($tenantId, $userId, $data), + 'collection.fetch' => $this->collectionFetch($tenantId, $userId, $data), + 'collection.extant' => $this->collectionExtant($tenantId, $userId, $data), + 'collection.create' => $this->collectionCreate($tenantId, $userId, $data), + 'collection.update' => $this->collectionUpdate($tenantId, $userId, $data), + 'collection.delete' => $this->collectionDelete($tenantId, $userId, $data), + + // Entity operations + 'entity.list' => $this->entityList($tenantId, $userId, $data), + 'entity.fetch' => $this->entityFetch($tenantId, $userId, $data), + 'entity.extant' => $this->entityExtant($tenantId, $userId, $data), + 'entity.create' => $this->entityCreate($tenantId, $userId, $data), + 'entity.update' => $this->entityUpdate($tenantId, $userId, $data), + 'entity.delete' => $this->entityDelete($tenantId, $userId, $data), + 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), + 'entity.move' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), + 'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), + + default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) + }; + } + + // ==================== Provider Operations ==================== + + private function providerList(string $tenantId, string $userId, array $data): mixed { + + $sources = null; + if (isset($data['sources']) && is_array($data['sources'])) { + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + } + + return $this->chronoManager->providerList($tenantId, $userId, $sources); + + } + + private function providerFetch(string $tenantId, string $userId, array $data): mixed { + + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + + return $this->chronoManager->providerFetch($tenantId, $userId, $data['identifier']); + } + + private function providerExtant(string $tenantId, string $userId, array $data): mixed { + + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + return $this->chronoManager->providerExtant($tenantId, $userId, $sources); + + } + + + // ==================== Service Operations ===================== + + private function serviceList(string $tenantId, string $userId, array $data): mixed { + + $sources = null; + if (isset($data['sources']) && is_array($data['sources'])) { + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + } + + return $this->chronoManager->serviceList($tenantId, $userId, $sources); + + } + + private function serviceFetch(string $tenantId, string $userId, array $data): mixed { + + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + + return $this->chronoManager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']); + } + + private function serviceExtant(string $tenantId, string $userId, array $data): mixed { + + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + return $this->chronoManager->serviceExtant($tenantId, $userId, $sources); + } + + private function serviceCreate(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['data'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['data'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + return $this->chronoManager->serviceCreate( + $tenantId, + $userId, + $data['provider'], + $data['data'] + ); + } + + private function serviceUpdate(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + if (!isset($data['data'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['data'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + return $this->chronoManager->serviceUpdate( + $tenantId, + $userId, + $data['provider'], + $data['identifier'], + $data['data'] + ); + } + + private function serviceDelete(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + + return $this->chronoManager->serviceDelete( + $tenantId, + $userId, + $data['provider'], + $data['identifier'] + ); + } + + private function serviceTest(string $tenantId, string $userId, array $data): mixed { + + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + + if (!isset($data['identifier']) && !isset($data['location']) && !isset($data['identity'])) { + throw new InvalidArgumentException('Either a service identifier or location and identity must be provided for service test'); + } + + return $this->chronoManager->serviceTest( + $tenantId, + $userId, + $data['provider'], + $data['identifier'] ?? null, + $data['location'] ?? null, + $data['identity'] ?? null, + ); + } + + // ==================== Collection Operations ==================== + + private function collectionList(string $tenantId, string $userId, array $data): mixed { + $sources = null; + if (isset($data['sources']) && is_array($data['sources'])) { + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + } + + $filter = $data['filter'] ?? null; + $sort = $data['sort'] ?? null; + + return $this->chronoManager->collectionList($tenantId, $userId, $sources, $filter, $sort); + } + + private function collectionExtant(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + return $this->chronoManager->collectionExtant($tenantId, $userId, $sources); + } + + private function collectionFetch(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier']) && !is_int($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + + return $this->chronoManager->collectionFetch( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['identifier'] + ); + } + + private function collectionCreate(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (isset($data['collection']) && !is_string($data['collection']) && !is_int($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['properties'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['properties'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + return $this->chronoManager->collectionCreate( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['collection'] ?? null, + $data['properties'] + ); + } + + private function collectionUpdate(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier']) && !is_int($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['properties'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['properties'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + return $this->chronoManager->collectionUpdate( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['identifier'], + $data['properties'] + ); + } + + private function collectionDelete(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier']) && !is_int($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + + return $this->chronoManager->collectionDelete( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['identifier'], + $data['options'] ?? [] + ); + } + + // ==================== Entity Operations ==================== + + private function entityList(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + $filter = $data['filter'] ?? null; + $sort = $data['sort'] ?? null; + $range = $data['range'] ?? null; + + return $this->chronoManager->entityList($tenantId, $userId, $sources, $filter, $sort, $range); + + } + + private function entityFetch(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_MISSING_COLLECTION); + } + if (!is_string($data['collection']) && !is_int($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['identifiers'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIERS); + } + if (!is_array($data['identifiers'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIERS); + } + + return $this->chronoManager->entityFetch( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['collection'], + $data['identifiers'] + ); + } + + private function entityExtant(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + return $this->chronoManager->entityExtant($tenantId, $userId, $sources); + } + + private function entityCreate(string $tenantId, string $userId, array $data = []): mixed { + + if (!isset($data['provider']) || !is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service']) || !is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + $properties = $data['properties'] ?? $data['data'] ?? null; + if (!is_array($properties)) { + throw new InvalidArgumentException('Invalid parameter: properties must be an array'); + } + $options = $data['options'] ?? []; + + return $this->chronoManager->entityCreate($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $properties, $options); + + } + + private function entityUpdate(string $tenantId, string $userId, array $data = []): mixed { + + if (!isset($data['provider']) || !is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service']) || !is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + $properties = $data['properties'] ?? $data['data'] ?? null; + if (!is_array($properties)) { + throw new InvalidArgumentException('Invalid parameter: properties must be an array'); + } + + return $this->chronoManager->entityUpdate($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $data['identifier'], $properties); + + } + + private function entityDelete(string $tenantId, string $userId, array $data = []): mixed { + + if (!isset($data['provider']) || !is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service']) || !is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + + return $this->chronoManager->entityDelete($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $data['identifier']); + + } + + private function entityDelta(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + return $this->chronoManager->entityDelta($tenantId, $userId, $sources); + } + +} \ No newline at end of file diff --git a/lib/Controllers/EntityController.php b/lib/Controllers/EntityController.php deleted file mode 100644 index b8847e7..0000000 --- a/lib/Controllers/EntityController.php +++ /dev/null @@ -1,202 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ChronoManager\Controllers; - -use KTXC\Http\Response\JsonResponse; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; -use KTXF\Controller\ControllerAbstract; -use KTXF\Routing\Attributes\AuthenticatedRoute; -use InvalidArgumentException; -use KTXF\Resource\Selector\SourceSelector; -use KTXM\ChronoManager\Manager; - -class EntityController extends ControllerAbstract { - - public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, - private Manager $chronoManager, - ) {} - - /** - * List entities for a specific user - * - * @param SourceSelector|null $sources entity sources - * @param array|null $filter entity filter - * @param array|null $sort entity sort - * @param array|null $range entity range - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/entity/list', name: 'chronomanager.entity.list', methods: ['POST'])] - - public function list(?SourceSelector $sources = null, ?array $filter = null, ?array $sort = null, ?array $range = null, ?string $uid = null): JsonResponse { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // retrieve entities - $responseData = $this->chronoManager->entityList($tenantId, $userId, $sources, $filter, $sort, $range); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } - - /** - * Delta of entity changes since last request - * - * @param SourceSelector $sources entity sources - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/entity/delta', name: 'chronomanager.entity.delta', methods: ['POST'])] - - public function delta(SourceSelector $sources, ?string $uid = null): JsonResponse { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // retrieve entity delta - $responseData = $this->chronoManager->entityDelta($tenantId, $userId, $sources); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } - - /** - * Confirm if specific entities are available for a specific user - * - * @param SourceSelector $sources entity sources - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/entity/extant', name: 'chronomanager.entity.extant', methods: ['POST'])] - - public function extant(SourceSelector $sources, ?string $uid = null): JsonResponse { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // retrieve entity status - $responseData = $this->chronoManager->entityExtant($tenantId, $userId, $sources); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } - - /** - * Fetch specific entities from a specific collection - * - * @param string|null $uid user identifier - * @param string $provider provider identifier - * @param string $service service identifier - * @param string|int $collection collection identifier - * @param array $identifiers entity identifiers - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/entity/fetch', name: 'chronomanager.entity.fetch', methods: ['POST'])] - - public function fetch(string $provider, string $service, string|int $collection, array $identifiers, ?string $uid = null): JsonResponse { - try { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // retrieve entities - $responseData = $this->chronoManager->entityFetch($tenantId, $userId, $provider, $service, $collection, $identifiers); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } catch (InvalidArgumentException $e) { - return new JsonResponse(['error' => $e->getMessage()], JsonResponse::HTTP_BAD_REQUEST); - } - } - - /** - * Create a new entity in a collection - * - * @param string $provider provider identifier - * @param string $service service identifier - * @param string|int $collection collection identifier - * @param array $data entity to create - * @param array $options additional options - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/entity/create', name: 'chronomanager.entity.create', methods: ['POST'])] - - public function create(string $provider, string $service, string|int $collection, array $data, array $options = [], ?string $uid = null): JsonResponse { - try { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // create entity - $responseData = $this->chronoManager->entityCreate($tenantId, $userId, $provider, $service, $collection, $data, $options); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } catch (InvalidArgumentException $e) { - return new JsonResponse(['error' => $e->getMessage()], JsonResponse::HTTP_BAD_REQUEST); - } - } - - /** - * Modify an existing entity in a collection - * - * @param string $provider provider identifier - * @param string $service service identifier - * @param string|int $collection collection identifier - * @param string|int $identifier entity identifier - * @param array $entity entity with modifications - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/entity/modify', name: 'chronomanager.entity.modify', methods: ['POST'])] - - public function modify(string $provider, string $service, string|int $collection, string|int $identifier, array $data, ?string $uid = null): JsonResponse { - try { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // modify entity - $responseData = $this->chronoManager->entityModify($tenantId, $userId, $provider, $service, $collection, $identifier, $data); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } catch (InvalidArgumentException $e) { - return new JsonResponse(['error' => $e->getMessage()], JsonResponse::HTTP_BAD_REQUEST); - } - } - - /** - * Delete an entity from a collection - * - * @param string $provider provider identifier - * @param string $service service identifier - * @param string|int $collection collection identifier - * @param string|int $identifier entity identifier - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/entity/destroy', name: 'chronomanager.entity.destroy', methods: ['POST'])] - public function destroy(string $provider, string $service, string|int $collection, string|int $identifier, ?string $uid = null): JsonResponse { - try { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // destroy entity - $success = $this->chronoManager->entityDestroy($tenantId, $userId, $provider, $service, $collection, $identifier); - - return new JsonResponse(['success' => $success], $success ? JsonResponse::HTTP_OK : JsonResponse::HTTP_NOT_FOUND); - } catch (InvalidArgumentException $e) { - return new JsonResponse(['error' => $e->getMessage()], JsonResponse::HTTP_BAD_REQUEST); - } - } - -} diff --git a/lib/Controllers/ProviderController.php b/lib/Controllers/ProviderController.php deleted file mode 100644 index 45680cc..0000000 --- a/lib/Controllers/ProviderController.php +++ /dev/null @@ -1,50 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ChronoManager\Controllers; - -use KTXC\Http\Response\JsonResponse; -use KTXF\Controller\ControllerAbstract; -use KTXF\Resource\Selector\SourceSelector; -use KTXF\Routing\Attributes\AuthenticatedRoute; -use KTXM\ChronoManager\Manager; - -class ProviderController extends ControllerAbstract { - - public function __construct( - private Manager $chronoManager, - ) {} - - /** - * Retrieve list of available providers - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/provider/list', name: 'chronomanager.provider.list', methods: ['GET'])] - - public function list(): JsonResponse { - $providers = $this->chronoManager->providerList(); - return new JsonResponse($providers, JsonResponse::HTTP_OK); - } - - /** - * Confirm which providers are available - * - * @param SourceSelector $sources provider sources - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/provider/extant', name: 'chronomanager.provider.extant', methods: ['POST'])] - - public function extant(SourceSelector $sources): JsonResponse { - $responseData = $this->chronoManager->providerExtant($sources); - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } - -} diff --git a/lib/Controllers/ServiceController.php b/lib/Controllers/ServiceController.php deleted file mode 100644 index c77bc0a..0000000 --- a/lib/Controllers/ServiceController.php +++ /dev/null @@ -1,95 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ChronoManager\Controllers; - -use KTXC\Http\Response\JsonResponse; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; -use KTXF\Controller\ControllerAbstract; -use KTXF\Routing\Attributes\AuthenticatedRoute; -use InvalidArgumentException; -use KTXF\Resource\Selector\SourceSelector; -use KTXM\ChronoManager\Manager; - -class ServiceController extends ControllerAbstract { - - public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, - private Manager $chronoManager, - ) {} - - /** - * Retrieve services for a specific user - * - * @param SourceSelector|null $sources service sources - * @param array|null $filter service filter options - * @param array|null $sort service sorting options - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/service/list', name: 'chronomanager.service.list', methods: ['POST'])] - - public function list(?SourceSelector $sources = null, ?array $filter = null, ?array $sort = null, ?string $uid = null): JsonResponse { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // retrieve services - $responseData = $this->chronoManager->serviceList($tenantId, $userId, $sources, $filter, $sort); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } - - /** - * Confirm if specific services are available for a specific user - * - * @param SourceSelector $sources service sources - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/service/extant', name: 'chronomanager.service.extant', methods: ['POST'])] - - public function extant(SourceSelector $sources, ?string $uid = null): JsonResponse { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // retrieve services status - $responseData = $this->chronoManager->serviceExtant($tenantId, $userId, $sources); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } - - /** - * Fetch specific service for a specific user - * - * @param string $provider provider identifier - * @param string $identifier service identifier - * @param string|null $uid user identifier - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/service/fetch', name: 'chronomanager.service.fetch', methods: ['POST'])] - public function fetch(string $provider, string $identifier, ?string $uid = null): JsonResponse { - try { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); - // retrieve service - $responseData = $this->chronoManager->serviceFetch($tenantId, $userId, $provider, $identifier); - - return new JsonResponse($responseData, JsonResponse::HTTP_OK); - } catch (InvalidArgumentException $e) { - return new JsonResponse(['error' => $e->getMessage()], JsonResponse::HTTP_BAD_REQUEST); - } - } - -} diff --git a/lib/Manager.php b/lib/Manager.php index 0129180..a605d27 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -6,21 +6,34 @@ namespace KTXM\ChronoManager; use InvalidArgumentException; use KTXC\Resource\ProviderManager; +use KTXF\Chrono\Collection\CollectionBaseInterface; +use KTXF\Chrono\Collection\CollectionMutableInterface; use KTXF\Chrono\Collection\ICollectionBase; -use KTXF\Chrono\Entity\IEntityBase; -use KTXF\Chrono\Provider\IProviderBase; -use KTXF\Chrono\Service\IServiceBase; -use KTXF\Chrono\Service\IServiceCollectionMutable; -use KTXF\Chrono\Service\IServiceEntityMutable; -use KTXF\Resource\Provider\ProviderInterface; +use KTXF\Chrono\Provider\ProviderBaseInterface; +use KTXF\Chrono\Provider\ProviderServiceMutateInterface; +use KTXF\Chrono\Provider\ProviderServiceTestInterface; +use KTXF\Chrono\Service\ServiceBaseInterface; +use KTXF\Chrono\Service\ServiceCollectionMutableInterface; +use KTXF\Chrono\Service\ServiceEntityMutableInterface; +use KTXF\Chrono\Entity\EntityBaseInterface; +use KTXF\Chrono\Entity\EntityMutableInterface; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Provider\ResourceServiceIdentityInterface; +use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeType; use KTXF\Resource\Selector\CollectionSelector; use KTXF\Resource\Selector\EntitySelector; use KTXF\Resource\Selector\ServiceSelector; use KTXF\Resource\Selector\SourceSelector; +use KTXF\Resource\Sort\ISort; use Psr\Log\LoggerInterface; +/** + * Chrono Manager + * + * Provides unified chrono operations across multiple providers + */ class Manager { public function __construct( @@ -33,13 +46,32 @@ class Manager { * * @param SourceSelector|null $sources collection of provider identifiers * - * @return array collection of available providers e.g. ['provider1' => IProvider, 'provider2' => IProvider] + * @return array collection of available providers e.g. ['provider1' => IProvider, 'provider2' => IProvider] */ - public function providerList(?SourceSelector $sources = null): array { + public function providerList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { // determine filter from sources $filter = ($sources !== null && $sources->identifiers() !== []) ? $sources->identifiers() : null; // retrieve providers from provider manager - return $this->providerManager->providers(ProviderInterface::TYPE_CHRONO, $filter); + return $this->providerManager->providers(ProviderBaseInterface::TYPE_CHRONO, $filter); + } + + /** + * Retrieve specific provider for specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string $provider provider identifier + * + * @return ProviderBaseInterface + * @throws InvalidArgumentException + */ + public function providerFetch(string $tenantId, string $userId, string $provider): ProviderBaseInterface { + // retrieve provider + $providers = $this->providerList($tenantId, $userId, new SourceSelector([$provider => true])); + if (!isset($providers[$provider])) { + throw new InvalidArgumentException("Provider '$provider' not found"); + } + return $providers[$provider]; } /** @@ -49,12 +81,11 @@ class Manager { * * @return array collection of providers and their availability status e.g. ['provider1' => true, 'provider2' => false] */ - public function providerExtant(?SourceSelector $sources = null): array { + public function providerExtant(string $tenantId, string $userId, SourceSelector $sources): array { // determine which providers are available - $providerFilter = $sources?->identifiers() ?? []; - $providersResolved = $this->providerManager->providers(ProviderInterface::TYPE_CHRONO, $providerFilter); + $providersResolved = $this->providerList($tenantId, $userId, $sources); $providersAvailable = array_keys($providersResolved); - $providersUnavailable = array_diff($providerFilter, $providersAvailable); + $providersUnavailable = array_diff($sources->identifiers(), $providersAvailable); // construct response data $responseData = array_merge( array_fill_keys($providersAvailable, true), @@ -63,23 +94,6 @@ class Manager { return $responseData; } - /** - * Retrieve specific provider for specific user - * - * @param string $provider provider identifier - * - * @return IProviderBase - * @throws InvalidArgumentException - */ - public function providerFetch(string $provider): IProviderBase { - // retrieve provider - $providers = $this->providerList(new SourceSelector([$provider => true])); - if (!isset($providers[$provider])) { - throw new InvalidArgumentException('Provider not found: ' . $provider); - } - return $providers[$provider]; - } - /** * Retrieve available services for specific user * @@ -87,21 +101,42 @@ class Manager { * @param string $userId user identifier * @param SourceSelector|null $sources list of provider and service identifiers * - * @return array> collections of available services e.g. ['provider1' => ['service1' => IServiceBase], 'provider2' => ['service2' => IServiceBase]] + * @return array> collections of available services e.g. ['provider1' => ['service1' => IServiceBase], 'provider2' => ['service2' => IServiceBase]] */ public function serviceList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { // retrieve providers - $providers = $this->providerList($sources); + $providers = $this->providerList($tenantId, $userId, $sources); // retrieve services for each provider $responseData = []; foreach ($providers as $provider) { - $serviceFilter = $sources[$provider->id()] instanceof ServiceSelector ? $sources[$provider->id()]->identifiers() : []; + $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; $services = $provider->serviceList($tenantId, $userId, $serviceFilter); - $responseData[$provider->id()] = $services; + $responseData[$provider->identifier()] = $services; } return $responseData; } + /** + * Retrieve service for specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string $providerId provider identifier + * @param string|int $serviceId service identifier + * + * @return ServiceBaseInterface + * @throws InvalidArgumentException + */ + public function serviceFetch(string $tenantId, string $userId, string $providerId, string|int $serviceId): ServiceBaseInterface { + // retrieve provider and service + $service = $this->providerFetch($tenantId, $userId, $providerId)->serviceFetch($tenantId, $userId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service '$serviceId' not found for provider '$providerId'"); + } + // retrieve services + return $service; + } + /** * Confirm which services are available * @@ -111,13 +146,9 @@ class Manager { * * @return array collection of providers and their availability status e.g. ['provider1' => ['service1' => false], 'provider2' => ['service2' => true, 'service3' => true]] */ - public function serviceExtant(string $tenantId, string $userId, ?SourceSelector $sources = null): array { - // confirm that sources are provided - if ($sources === null) { - $sources = new SourceSelector([]); - } + public function serviceExtant(string $tenantId, string $userId, SourceSelector $sources): array { // retrieve providers - $providers = $this->providerList($sources); + $providers = $this->providerList($tenantId, $userId, $sources); $providersRequested = $sources->identifiers(); $providersUnavailable = array_diff($providersRequested, array_keys($providers)); @@ -126,53 +157,226 @@ class Manager { // retrieve services for each available provider foreach ($providers as $provider) { - $serviceSelector = $sources[$provider->id()]; + $serviceSelector = $sources[$provider->identifier()]; $serviceAvailability = $provider->serviceExtant($tenantId, $userId, ...$serviceSelector->identifiers()); - $responseData[$provider->id()] = $serviceAvailability; + $responseData[$provider->identifier()] = $serviceAvailability; } return $responseData; } /** - * Retrieve service for specific user + * Create a new service * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param string $provider provider identifier - * @param string|int $service service identifier - * - * @return IServiceBase - * @throws InvalidArgumentException + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string $providerId Provider identifier + * @param array $data Service configuration data + * + * @return ServiceBaseInterface Created service + * + * @throws InvalidArgumentException If provider doesn't support service creation */ - public function serviceFetch(string $tenantId, string $userId, string $provider, string|int $service): IServiceBase { - $providerInstance = $this->providerFetch($provider); - $serviceInstance = $providerInstance->serviceFetch($tenantId, $userId, $service); - if ($serviceInstance === null) { - throw new InvalidArgumentException('Service not found: ' . $service); + public function serviceCreate(string $tenantId, ?string $userId, string $providerId, array $data): ServiceBaseInterface { + // retrieve provider and service + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); } - return $serviceInstance; + + // Create a fresh service instance + $service = $provider->serviceFresh(); + + // Deserialize the data into the service + $service->jsonDeserialize($data); + + // Create the service + $serviceId = $provider->serviceCreate($tenantId, $userId, $service); + + // Fetch and return the created service + return $provider->serviceFetch($tenantId, $userId, $serviceId); } /** - * Retrieve available collections for specific user + * Update an existing service * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param SourceSelector|null $sources list of provider and service identifiers - * - * @return array> collections of available services e.g. ['provider1' => ['service1' => [ICollectionBase], 'service2' => [ICollectionBase]]] + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * @param array $data Updated service configuration data + * + * @return ServiceBaseInterface Updated service + * + * @throws InvalidArgumentException If provider doesn't support service modification or service not found */ - public function collectionList(string $tenantId, string $userId, ?SourceSelector $sources = null, ?array $filter = null, ?array $sort = null): array { + public function serviceUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, array $data): ServiceBaseInterface { + // retrieve provider and service + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); + } + + // Fetch existing service + $service = $provider->serviceFetch($tenantId, $userId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service '$serviceId' not found"); + } + + // Update with new data + $service->jsonDeserialize($data); + + // Modify the service + $provider->serviceModify($tenantId, $userId, $service); + + // Fetch and return the updated service + return $provider->serviceFetch($tenantId, $userId, $serviceId); + } + + /** + * Delete a service + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * + * @return bool True if service was deleted + * + * @throws InvalidArgumentException If provider doesn't support service deletion or service not found + */ + public function serviceDelete(string $tenantId, string $userId, string $providerId, string|int $serviceId): bool { + // retrieve provider and service + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); + } + + // Fetch existing service + $service = $provider->serviceFetch($tenantId, $userId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service '$serviceId' not found"); + } + + // Delete the service + return $provider->serviceDestroy($tenantId, $userId, $service); + } + + + /** + * Test a mail service connection + * + * Tests connectivity and authentication for either an existing service + * or a fresh configuration. Delegates to the appropriate provider. + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param string $providerId Provider ID (for existing service or targeted test) + * @param string|int|null $serviceId Service ID (for existing service test) + * @param ResourceServiceLocationInterface|array|null $location Service location (for fresh config test) + * @param ResourceServiceIdentityInterface|array|null $identity Service credentials (for fresh config test) + * + * @return array Test results + * + * @throws InvalidArgumentException If invalid parameters + */ + public function serviceTest( + string $tenantId, + string $userId, + string $providerId, + string|int|null $serviceId = null, + ResourceServiceLocationInterface|array|null $location = null, + ResourceServiceIdentityInterface|array|null $identity = null + ): array { + // retrieve provider + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceTestInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service testing"); + } + + // Testing existing service + if ($providerId !== null && $serviceId !== null) { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service not found: $providerId/$serviceId"); + } + + try { + return $provider->serviceTest($service); + } catch (\Throwable $e) { + throw new InvalidArgumentException('Service test failed: ' . $e->getMessage()); + } + } + + // Testing fresh configuration + if ($location !== null && $identity !== null) { + + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support fresh service configuration testing"); + } + + if (empty($location['type'])) { + throw new InvalidArgumentException('Service location not valid'); + } + + if (empty($identity['type'])) { + throw new InvalidArgumentException('Service identity not valid'); + } + + /** @var ServiceMutableInterface $service */ + $service = $provider->serviceFresh(); + if ($location instanceof ResourceServiceLocationInterface === false) { + $location = $service->freshLocation($location['type'], (array)$location); + $service->setLocation($location); + } + if ($identity instanceof ResourceServiceIdentityInterface === false) { + $identity = $service->freshIdentity($identity['type'], (array)$identity); + $service->setIdentity($identity); + } + + return $provider->serviceTest($service); + } + + throw new InvalidArgumentException( + 'Either (provider + service) or (provider + location + identity) must be provided' + ); + } + + // ==================== Collection Operations ==================== + + /** + * List collections across services + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector|null $sources Provider/service sources + * @param IFilter|null $filter Collection filter + * @param ISort|null $sort Collection sort + * + * @return array>> Collections grouped by provider/service + */ + public function collectionList(string $tenantId, ?string $userId, ?SourceSelector $sources = null, ?IFilter $filter = null, ?ISort $sort = null): array { // confirm that sources are provided if ($sources === null) { $sources = new SourceSelector([]); } // retrieve providers - $providers = $this->providerList($sources); + $providers = $this->providerList($tenantId, $userId, $sources); // retrieve services for each provider $responseData = []; foreach ($providers as $provider) { - $serviceFilter = $sources[$provider->id()] instanceof ServiceSelector ? $sources[$provider->id()]->identifiers() : []; + $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; + /** @var ServiceBaseInterface[] $services */ $services = $provider->serviceList($tenantId, $userId, $serviceFilter); // retrieve collections for each service foreach ($services as $service) { @@ -192,9 +396,9 @@ class Manager { $collectionSort->condition($attribute, $direction); } } - $collections = $service->collectionList($collectionFilter, $collectionSort); + $collections = $service->collectionList('', $collectionFilter, $collectionSort); if ($collections !== []) { - $responseData[$provider->id()][$service->id()] = $collections; + $responseData[$provider->identifier()][$service->identifier()] = $collections; } } } @@ -202,21 +406,19 @@ class Manager { } /** - * Confirm which collections are available + * Check if collections exist * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param SourceSelector $sources collection of provider and service identifiers to confirm - * - * @return array collection of providers and their availability status e.g. ['provider1' => ['service1' => ['collection1' => true, 'collection2' => false]]]] + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector $sources Collection sources with identifiers + * + * @return array>> Existence map grouped by provider/service */ - public function collectionExtant(string $tenantId, string $userId, SourceSelector $sources): array { - // confirm that sources are provided - if ($sources === null) { - $sources = new SourceSelector([]); - } + public function collectionExtant(string $tenantId, ?string $userId, SourceSelector $sources): array { // retrieve available providers - $providers = $this->providerList($sources); + $providers = $this->providerList($tenantId, $userId, $sources); $providersRequested = $sources->identifiers(); $providersUnavailable = array_diff($providersRequested, array_keys($providers)); @@ -225,19 +427,20 @@ class Manager { // check services and collections for each available provider foreach ($providers as $provider) { - $serviceSelector = $sources[$provider->id()]; + $serviceSelector = $sources[$provider->identifier()]; $servicesRequested = $serviceSelector->identifiers(); + /** @var ServiceBaseInterface[] $servicesAvailable */ $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); // mark unavailable services as false if ($servicesUnavailable !== []) { - $responseData[$provider->id()] = array_fill_keys($servicesUnavailable, false); + $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); } // confirm collections for each available service foreach ($servicesAvailable as $service) { - $collectionSelector = $serviceSelector[$service->id()]; + $collectionSelector = $serviceSelector[$service->identifier()]; $collectionsRequested = $collectionSelector->identifiers(); if ($collectionsRequested === []) { @@ -245,142 +448,179 @@ class Manager { } // check each requested collection - foreach ($collectionsRequested as $collectionId) { - $responseData[$provider->id()][$service->id()][$collectionId] = $service->collectionExtant((string)$collectionId); - } + $collectionsAvailable = $service->collectionExtant(...$collectionsRequested); + $collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable)); + $responseData[$provider->identifier()][$service->identifier()] = array_merge( + $collectionsAvailable, + array_fill_keys($collectionsUnavailable, false) + ); } } return $responseData; } /** - * Retrieve specific collection for specific user + * Fetch a specific collection + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * @param string|int $collectionId Collection identifier + * + * @return CollectionBaseInterface|null + */ + public function collectionFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId): ?CollectionBaseInterface { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + if ($service === null) { + return null; + } + // retrieve collection + return $service->collectionFetch($collectionId); + } + + /** + * Create a new collection for a specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string $providerId provider identifier + * @param string|int $serviceId service identifier + * @param string|int|null $collectionId collection identifier (parent collection) + * @param CollectionMutableInterface|array $object collection to create + * @param array $options additional options for creation + * + * @return CollectionBaseInterface + * @throws InvalidArgumentException + */ + public function collectionCreate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int|null $collectionId, CollectionMutableInterface|array $object, array $options = []): CollectionBaseInterface { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + // Check if service supports collection creation + if (!($service instanceof ServiceCollectionMutableInterface)) { + throw new InvalidArgumentException("Service does not support collection mutations"); + } + if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_CREATE)) { + throw new InvalidArgumentException("Service is not capable of creating collections"); + } + + if (is_array($object)) { + $collection = $service->collectionFresh(); + $collection->getProperties()->jsonDeserialize($object); + } else { + $collection = $object; + } + + // Create collection + return $service->collectionCreate($collectionId, $collection, $options); + } + + /** + * Modify an existing collection for a specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string $providerId provider identifier * @param string|int $serviceId service identifier * @param string|int $collectionId collection identifier + * @param CollectionMutableInterface|array $object collection to modify * - * @return ICollectionBase + * @return CollectionBaseInterface * @throws InvalidArgumentException */ - public function collectionFetch(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId): ICollectionBase { + public function collectionUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, CollectionMutableInterface|array $object): CollectionBaseInterface { + // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - $collection = $service->collectionFetch($collectionId); - if ($collection === null) { - throw new InvalidArgumentException('Collection not found: ' . $collectionId); + + // Check if service supports collection creation + if (!($service instanceof ServiceCollectionMutableInterface)) { + throw new InvalidArgumentException("Service does not support collection mutations"); } - return $collection; + if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_UPDATE)) { + throw new InvalidArgumentException("Service is not capable of updating collections"); + } + + if (is_array($object)) { + $collection = $service->collectionFresh(); + $collection->getProperties()->jsonDeserialize($object); + } else { + $collection = $object; + } + + // Update collection + return $service->collectionUpdate($collectionId, $collection); } /** - * Create a new collection + * Delete a specific collection * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param ICollectionBase|array $collection collection to create - * @param array $options additional options - * - * @return ICollectionBase - * @throws InvalidArgumentException + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * @param string|int $collectionId Collection identifier + * + * @return CollectionBaseInterface|null */ - public function collectionCreate(string $tenantId, string $userId, string $providerId, string|int $serviceId, ICollectionBase|array $collection, array $options = []): ICollectionBase { + public function collectionDelete(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $options = []): bool { + // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if (!($service instanceof IServiceCollectionMutable)) { - throw new InvalidArgumentException('Service does not support collection creation'); + + // Check if service supports collection deletion + if (!($service instanceof ServiceCollectionMutableInterface)) { + throw new InvalidArgumentException("Service does not support collection mutations"); } - // convert array to collection object if needed - if (is_array($collection)) { - $collectionObject = $service->collectionFresh(); - $collectionObject->jsonDeserialize($collection); - $collection = $collectionObject; + if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_DELETE)) { + throw new InvalidArgumentException("Service is not capable of deleting collections"); } - // create collection - return $service->collectionCreate('', $collection, $options); + + $force = $options['force'] ?? false; + $recursive = $options['recursive'] ?? false; + + // delete collection + return $service->collectionDelete($collectionId, $force, $recursive); } - /** - * Modify an existing collection - * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param ICollectionBase|array $collectionData collection data - * - * @return ICollectionBase - * @throws InvalidArgumentException - */ - public function collectionModify(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, ICollectionBase|array $collectionData): ICollectionBase { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if (!($service instanceof IServiceCollectionMutable)) { - throw new InvalidArgumentException('Service does not support collection modification'); - } - // convert array to collection object if needed - if (is_array($collectionData)) { - $collectionObject = $service->collectionFresh(); - $collectionObject->jsonDeserialize($collectionData); - $collectionData = $collectionObject; - } - // modify collection - return $service->collectionModify($collectionId, $collectionData); - } + // ==================== Message Operations ==================== /** - * Destroy a collection + * List messages in a collection * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * - * @return bool - * @throws InvalidArgumentException - */ - public function collectionDestroy(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId): bool { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if (!($service instanceof IServiceCollectionMutable)) { - throw new InvalidArgumentException('Service does not support collection destruction'); - } - return $service->collectionDestroy($collectionId); - } - - /** - * Retrieve available entities for specific user + * @since 2025.05.01 * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param SourceSelector $sources list of provider and service identifiers - * - * @return array> collections of store enteties e.g. ['provider1' => ['service1' => [ICollectionBase], 'service2' => [ICollectionBase]]] + * @param string $tenantId Tenant identifier + * @param string $userId User identifier + * @param SourceSelector $sources Message sources with collection identifiers + * @param array|null $filter Message filter + * @param array|null $sort Message sort + * @param array|null $range Message range/pagination + * + * @return array>>> Messages grouped by provider/service/collection */ - public function entityList(string $tenantId, string $userId, ?SourceSelector $sources = null, ?array $filter = null, ?array $sort = null, ?array $range = null): array { - // confirm that sources are provided - if ($sources === null) { - $sources = new SourceSelector([]); - } + public function entityList(string $tenantId, string $userId, SourceSelector $sources, array|null $filter = null, array|null $sort = null, array|null $range = null): array { // retrieve providers - $providers = $this->providerList($sources); + $providers = $this->providerList($tenantId, $userId, $sources); // retrieve services for each provider $responseData = []; foreach ($providers as $provider) { // retrieve services for each provider - $serviceSelector = $sources[$provider->id()]; - $servicesSelected = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; - $services = $provider->serviceList($tenantId,$userId, $servicesSelected); - foreach ($services as $service) { + $serviceSelector = $sources[$provider->identifier()]; + $servicesSelected = $provider->serviceList($tenantId,$userId, $serviceSelector->identifiers()); + /** @var ServiceBaseInterface $service */ + foreach ($servicesSelected as $service) { // retrieve collections for each service - $collectionSelector = $serviceSelector[$service->id()]; + $collectionSelector = $serviceSelector[$service->identifier()]; $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; if ($collectionSelected === []) { - $collections = $service->collectionList(); + $collections = $service->collectionList(''); $collectionSelected = array_map( - fn(ICollectionBase $collection): string|int => $collection->id(), + fn($collection) => $collection->identifier(), $collections ); } @@ -428,67 +668,45 @@ class Manager { if ($entities === []) { continue; } - $responseData[$provider->id()][$service->id()][$collectionId] = $entities; + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $entities; } } - } + } + return $responseData; } - - /** - * Confirm which entities are available + + /** + * Fetch specific messages * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param array $sources collection of provider and service identifiers to confirm - * - * @return array collection of providers and their availability status + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * @param string|int $collectionId Collection identifier + * @param array $identifiers Message identifiers + * + * @return array Messages indexed by ID */ - public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array { - // confirm that sources are provided - if ($sources === null) { - $sources = new SourceSelector([]); - } - // retrieve providers - $providers = $this->providerList($sources); - $providersRequested = $sources->identifiers(); - $providersUnavailable = array_diff($providersRequested, array_keys($providers)); - // initialize response with unavailable providers - $responseData = array_fill_keys($providersUnavailable, false); - // iterate through available providers - foreach ($providers as $provider) { - $serviceSelector = $sources[$provider->id()]; - $servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; - $services = $provider->serviceList($tenantId, $userId, $servicesRequested); - $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); - if ($servicesUnavailable !== []) { - $responseData[$provider->id()] = array_fill_keys($servicesUnavailable, false); - } - // iterate through available services - foreach ($services as $service) { - $collectionSelector = $serviceSelector[$service->id()]; - $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; - if ($collectionsRequested === []) { - $responseData[$provider->id()][$service->id()] = false; - continue; - } - foreach ($collectionsRequested as $collection) { - $entitySelector = $collectionSelector[$collection] ?? null; - $responseData[$provider->id()][$service->id()][$collection] = $service->entityDelta($collection, $entitySelector); - } - } - } - return $responseData; + 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); + + // retrieve collection + return $service->entityFetch($collectionId, ...$identifiers); } /** - * Confirm which entities are available + * Check if messages exist * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param SourceSelector $sources collection of provider and service identifiers to confirm - * - * @return array collection of providers and their availability status e.g. ['provider1' => ['service1' => ['collection1' => ['entity1' => true, 'entity2' => false]]]] + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector $sources Message sources with identifiers + * + * @return array>>> Existence map grouped by provider/service/collection */ public function entityExtant(string $tenantId, string $userId, SourceSelector $sources): array { // confirm that sources are provided @@ -496,7 +714,7 @@ class Manager { $sources = new SourceSelector([]); } // retrieve available providers - $providers = $this->providerList($sources); + $providers = $this->providerList($tenantId, $userId, $sources); $providersRequested = $sources->identifiers(); $providersUnavailable = array_diff($providersRequested, array_keys($providers)); @@ -505,19 +723,20 @@ class Manager { // check services, collections, and entities for each available provider foreach ($providers as $provider) { - $serviceSelector = $sources[$provider->id()]; + $serviceSelector = $sources[$provider->identifier()]; $servicesRequested = $serviceSelector->identifiers(); + /** @var ServiceBaseInterface[] $servicesAvailable */ $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); // mark unavailable services as false if ($servicesUnavailable !== []) { - $responseData[$provider->id()] = array_fill_keys($servicesUnavailable, false); + $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); } // check collections and entities for each available service foreach ($servicesAvailable as $service) { - $collectionSelector = $serviceSelector[$service->id()]; + $collectionSelector = $serviceSelector[$service->identifier()]; $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; if ($collectionsRequested === []) { @@ -530,7 +749,8 @@ class Manager { $collectionExists = $service->collectionExtant((string)$collectionId); if (!$collectionExists) { - $responseData[$provider->id()][$service->id()][$collectionId] = false; + // collection doesn't exist, mark as false + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = false; continue; } @@ -539,10 +759,11 @@ class Manager { // handle both array of entity IDs and boolean true (meaning check if collection exists) if ($entitySelector instanceof EntitySelector) { - $entityIds = $entitySelector->identifiers(); - foreach ($entityIds as $entityId) { - $responseData[$provider->id()][$service->id()][$collectionId][$entityId] = $service->entityExtant((string)$collectionId, (string)$entityId)[$entityId] ?? false; - } + // check specific entities within the collection + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitySelector->identifiers()); + } elseif ($entitySelector === true) { + // just checking if collection exists (already confirmed above) + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = true; } } } @@ -551,20 +772,52 @@ class Manager { } /** - * Retrieve specific entities from a collection + * Get message delta/changes * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param string|int $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param array $identifiers entity identifiers - * - * @return array collection of entities + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector $sources Message sources with signatures + * + * @return array>> Delta grouped by provider/service/collection */ - public function entityFetch(string $tenantId, string $userId, string|int $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - return $service->entityFetch($collectionId, ...$identifiers); + public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array { + // confirm that sources are provided + if ($sources === null) { + $sources = new SourceSelector([]); + } + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + $providersRequested = $sources->identifiers(); + $providersUnavailable = array_diff($providersRequested, array_keys($providers)); + // initialize response with unavailable providers + $responseData = array_fill_keys($providersUnavailable, false); + // iterate through available providers + foreach ($providers as $provider) { + $serviceSelector = $sources[$provider->identifier()]; + $servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; + /** @var ServiceBaseInterface[] $services */ + $services = $provider->serviceList($tenantId, $userId, $servicesRequested); + $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); + if ($servicesUnavailable !== []) { + $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + } + // iterate through available services + foreach ($services as $service) { + $collectionSelector = $serviceSelector[$service->identifier()]; + $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; + if ($collectionsRequested === []) { + $responseData[$provider->identifier()][$service->identifier()] = false; + continue; + } + foreach ($collectionsRequested as $collection) { + $entitySelector = $collectionSelector[$collection] ?? null; + $responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector); + } + } + } + return $responseData; } /** @@ -575,24 +828,31 @@ class Manager { * @param string $providerId provider identifier * @param string|int $serviceId service identifier * @param string|int $collectionId collection identifier - * @param IEntityBase|array $entity entity to create + * @param EntityMutableInterface|array $entity entity to create * @param array $options additional options * - * @return IEntityBase + * @return EntityBaseInterface * @throws InvalidArgumentException */ - public function entityCreate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, IEntityBase|array $entity, array $options = []): IEntityBase { + public function entityCreate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, EntityMutableInterface|array $object, array $options = []): EntityBaseInterface { + // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if (!($service instanceof IServiceEntityMutable)) { - throw new InvalidArgumentException('Service does not support entity creation'); + + // Check if service supports entity creation + if (!($service instanceof ServiceEntityMutableInterface)) { + throw new InvalidArgumentException("Service does not support entity mutations"); } - // convert array to entity object if needed - if (is_array($entity)) { - $entityObject = $service->entityFresh(); - $entityObject->jsonDeserialize($entity); - $entity = $entityObject; + if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_CREATE)) { + throw new InvalidArgumentException("Service is not capable of creating entities"); } - // create entity + + if (is_array($object)) { + $entity = $service->entityFresh(); + $entity->getProperties()->jsonDeserialize($object); + } else { + $entity = $object; + } + return $service->entityCreate($collectionId, $entity, $options); } @@ -605,24 +865,31 @@ class Manager { * @param string|int $serviceId service identifier * @param string|int $collectionId collection identifier * @param string|int $identifier entity identifier - * @param IEntityBase|array $entity entity with modifications + * @param EntityBaseInterface|array $entity entity with modifications * - * @return IEntityBase + * @return EntityBaseInterface * @throws InvalidArgumentException */ - public function entityModify(string $tenantId, string $userId, string|int $providerId, string|int $serviceId, string|int $collectionId, string|int $identifier, IEntityBase|array $entity): IEntityBase { + public function entityUpdate(string $tenantId, string $userId, string|int $providerId, string|int $serviceId, string|int $collectionId, string|int $identifier, EntityBaseInterface|array $object): EntityBaseInterface { + // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if (!($service instanceof IServiceEntityMutable)) { - throw new InvalidArgumentException('Service does not support entity modification'); + + // Check if service supports entity creation + if (!($service instanceof ServiceEntityMutableInterface)) { + throw new InvalidArgumentException("Service does not support entity mutations"); } - // convert array to entity object if needed - if (is_array($entity)) { - $entityObject = $service->entityFresh(); - $entityObject->jsonDeserialize($entity); - $entity = $entityObject; + if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_CREATE)) { + throw new InvalidArgumentException("Service is not capable of creating entities"); } - // modify entity - return $service->entityModify($collectionId, $identifier, $entity); + + if (is_array($object)) { + $entity = $service->entityFresh(); + $entity->getProperties()->jsonDeserialize($object); + } else { + $entity = $object; + } + + return $service->entityUpdate($collectionId, $identifier, $entity); } /** @@ -638,12 +905,16 @@ class Manager { * @return bool * @throws InvalidArgumentException */ - public function entityDestroy(string $tenantId, string $userId, string|int $providerId, string|int $serviceId, string|int $collectionId, string|int $identifier): bool { + public function entityDelete(string $tenantId, string $userId, string|int $providerId, string|int $serviceId, string|int $collectionId, string|int $identifier): bool { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if (!($service instanceof IServiceEntityMutable)) { + + if (!($service instanceof ServiceEntityMutableInterface)) { throw new InvalidArgumentException('Service does not support entity destruction'); } - $entity = $service->entityDestroy($collectionId, $identifier); + + $entity = $service->entityDelete($collectionId, $identifier); + return $entity !== null; } + } diff --git a/src/main.ts b/src/main.ts index 7bebac0..95f6082 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,9 +10,9 @@ import { useServicesStore } from '@/stores/servicesStore' * It initializes the chronoStore which manages calendars, events, tasks, and journals state. */ -console.log('[ChronoManager] Booting Chrono Manager module...') +console.log('[Chrono Manager] Booting Chrono Manager module...') -console.log('[ChronoManager] Chrono Manager module booted successfully') +console.log('[Chrono Manager] Chrono Manager module booted successfully') // Export store for external use if needed export { useCollectionsStore, useEntitiesStore, useProvidersStore, useServicesStore } diff --git a/src/models/collection.ts b/src/models/collection.ts index 454e631..d3a2146 100644 --- a/src/models/collection.ts +++ b/src/models/collection.ts @@ -1,11 +1,8 @@ /** * Class model for Collection Interface */ -import type { - CollectionInterface, - CollectionContentsInterface, - CollectionPermissionsInterface, -} from "@/types/collection"; + +import type { CollectionContentsInterface, CollectionInterface, CollectionPropertiesInterface } from "@/types/collection"; export class CollectionObject implements CollectionInterface { @@ -13,81 +10,195 @@ export class CollectionObject implements CollectionInterface { constructor() { this._data = { - '@type': 'chrono:collection', - provider: null, - service: null, - in: null, - id: null, - label: null, - description: null, - priority: null, - visibility: null, - color: null, - enabled: true, + provider: '', + service: '', + collection: null, + identifier: '', signature: null, - permissions: {}, - contents: {}, + created: null, + modified: null, + properties: { + '@type': 'chrono.collection', + version: 1, + total: 0, + contents: {}, + label: '', + description: null, + rank: 0, + visibility: null, + color: null, + }, }; } fromJson(data: CollectionInterface): CollectionObject { this._data = data; + if (data.properties) { + this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface); + } else { + this._data.properties = new CollectionPropertiesObject(); + } return this; } toJson(): CollectionInterface { - return this._data; + const json = { ...this._data }; + if (this._data.properties instanceof CollectionPropertiesObject) { + json.properties = this._data.properties.toJson(); + } + return json; } clone(): CollectionObject { const cloned = new CollectionObject(); - cloned._data = JSON.parse(JSON.stringify(this._data)); + cloned._data = { ...this._data }; + cloned._data.properties = this.properties.clone(); return cloned; } - /** Properties */ + /** Immutable Properties */ + + get provider(): string { + return this._data.provider; + } + + get service(): string | number { + return this._data.service; + } + + get collection(): string | number | null { + return this._data.collection; + } + + get identifier(): string | number { + return this._data.identifier; + } + + get signature(): string | null | undefined { + return this._data.signature; + } + + get created(): string | null | undefined { + return this._data.created; + } + + get modified(): string | null | undefined { + return this._data.modified; + } + + get properties(): CollectionPropertiesObject { + if (this._data.properties instanceof CollectionPropertiesObject) { + return this._data.properties; + } + + if (this._data.properties) { + const hydrated = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface); + this._data.properties = hydrated; + return hydrated; + } + + return new CollectionPropertiesObject(); + } + + set properties(value: CollectionPropertiesObject) { + if (value instanceof CollectionPropertiesObject) { + this._data.properties = value as any; + } else { + this._data.properties = value; + } + } +} + +export class CollectionPropertiesObject implements CollectionPropertiesInterface { + + _data!: CollectionPropertiesInterface; + + constructor() { + this._data = { + '@type': 'chrono.collection', + version: 1, + total: 0, + contents: {}, + label: '', + description: null, + rank: null, + visibility: null, + color: null, + }; + } + + fromJson(data: CollectionPropertiesInterface): CollectionPropertiesObject { + this._data = data; + + const raw = this._data as any; + if ((!raw.contents || Object.keys(raw.contents).length === 0) && raw.content !== undefined && raw.content !== null) { + if (typeof raw.content === 'string') { + raw.contents = { + event: raw.content === 'event', + task: raw.content === 'task', + journal: raw.content === 'journal', + }; + } else if (typeof raw.content === 'object') { + raw.contents = raw.content; + } + } + + return this; + } + + toJson(): CollectionPropertiesInterface { + return this._data; + } + + clone(): CollectionPropertiesObject { + const cloned = new CollectionPropertiesObject(); + cloned._data = { ...this._data }; + return cloned; + } + + /** Immutable Properties */ get '@type'(): string { return this._data['@type']; } - get provider(): string | null { - return this._data.provider; + get version(): number { + return this._data.version; } - set provider(value: string | null) { - this._data.provider = value; + get total(): number | undefined { + return this._data.total; } - get service(): string | null { - return this._data.service; + get contents(): CollectionContentsInterface { + const raw = this._data as any; + + if (raw.contents && Object.keys(raw.contents).length > 0) { + return raw.contents; + } + + if (typeof raw.content === 'string') { + return { + event: raw.content === 'event', + task: raw.content === 'task', + journal: raw.content === 'journal', + }; + } + + if (raw.content && typeof raw.content === 'object') { + return raw.content; + } + + return {}; } - set service(value: string | null) { - this._data.service = value; + /** Mutable Properties */ + + get label(): string { + return this._data.label || ''; } - get in(): number | string | null { - return this._data.in; - } - - set in(value: number | string | null) { - this._data.in = value; - } - - get id(): number | string | null { - return this._data.id; - } - - set id(value: number | string | null) { - this._data.id = value; - } - - get label(): string | null { - return this._data.label; - } - - set label(value: string | null) { + set label(value: string) { this._data.label = value; } @@ -99,19 +210,19 @@ export class CollectionObject implements CollectionInterface { this._data.description = value; } - get priority(): number | null { - return this._data.priority; + get rank(): number | null { + return this._data.rank; } - set priority(value: number | null) { - this._data.priority = value; + set rank(value: number | null) { + this._data.rank = value; } - get visibility(): string | null { + get visibility(): boolean | null { return this._data.visibility; } - set visibility(value: string | null) { + set visibility(value: boolean | null) { this._data.visibility = value; } @@ -123,36 +234,4 @@ export class CollectionObject implements CollectionInterface { this._data.color = value; } - get enabled(): boolean { - return this._data.enabled; - } - - set enabled(value: boolean) { - this._data.enabled = value; - } - - get signature(): string | null { - return this._data.signature; - } - - set signature(value: string | null) { - this._data.signature = value; - } - - get permissions(): CollectionPermissionsInterface { - return this._data.permissions; - } - - set permissions(value: CollectionPermissionsInterface) { - this._data.permissions = value; - } - - get contents(): CollectionContentsInterface { - return this._data.contents; - } - - set contents(value: CollectionContentsInterface) { - this._data.contents = value; - } - -} +} \ No newline at end of file diff --git a/src/models/entity.ts b/src/models/entity.ts index fa85075..719c1b4 100644 --- a/src/models/entity.ts +++ b/src/models/entity.ts @@ -9,154 +9,118 @@ import { EventObject } from "./event"; import { TaskObject } from "./task"; import { JournalObject } from "./journal"; +type EntityPropertiesData = EventInterface | TaskInterface | JournalInterface; +type EntityPropertiesObject = EventObject | TaskObject | JournalObject; +type EntityInternal = Omit, 'properties'> & { + properties: EntityPropertiesData | EntityPropertiesObject; +}; + export class EntityObject implements EntityInterface { - _data!: EntityInterface; + _data!: EntityInternal; constructor() { this._data = { - '@type': 'chrono:entity', - version: 1, - in: null, - id: null, - createdOn: null, - createdBy: null, - modifiedOn: null, - modifiedBy: null, + provider: '', + service: '', + collection: '', + identifier: '', signature: null, - data: null, + created: null, + modified: null, + properties: new EventObject().toJson(), }; } fromJson(data: EntityInterface): EntityObject { - this._data = data; - if (data.data) { - const type = data.data.type; - if (type === 'task') { - this._data.data = new TaskObject().fromJson(data.data as TaskInterface); - } else if (type === 'journal') { - this._data.data = new JournalObject().fromJson(data.data as JournalInterface); - } else { - this._data.data = new EventObject().fromJson(data.data as EventInterface); - } + this._data = data as EntityInternal; + if (data.properties) { + this._data.properties = this.hydrateProperties(data.properties); } else { - this._data.data = null; + this._data.properties = new EventObject(); } return this; } - toJson(): EntityInterface { + toJson(): EntityInterface { const json = { ...this._data }; - if (this._data.data instanceof EventObject || - this._data.data instanceof TaskObject || - this._data.data instanceof JournalObject) { - json.data = this._data.data.toJson(); + if (this._data.properties instanceof EventObject || + this._data.properties instanceof TaskObject || + this._data.properties instanceof JournalObject) { + json.properties = this._data.properties.toJson(); } - return json; + return json as EntityInterface; } clone(): EntityObject { const cloned = new EntityObject(); - cloned._data = JSON.parse(JSON.stringify(this._data)); + cloned._data = JSON.parse(JSON.stringify(this.toJson())) as EntityInternal; return cloned; } - /** Properties */ + private hydrateProperties(properties: EntityPropertiesData): EntityPropertiesObject { + const type = properties.type; + if (type === 'task') { + return new TaskObject().fromJson(properties as TaskInterface); + } - get '@type'(): string { - return this._data['@type']; + if (type === 'journal') { + return new JournalObject().fromJson(properties as JournalInterface); + } + + return new EventObject().fromJson(properties as EventInterface); } - get version(): number { - return this._data.version; + /** Immutable Properties */ + + get provider(): string { + return this._data.provider; } - set version(value: number) { - this._data.version = value; + get service(): string { + return this._data.service; } - get in(): string | number | null { - return this._data.in; + get collection(): string | number { + return this._data.collection; } - set in(value: string | number | null) { - this._data.in = value; - } - - get id(): string | number | null { - return this._data.id; - } - - set id(value: string | number | null) { - this._data.id = value; - } - - get createdOn(): Date | null { - return this._data.createdOn; - } - - set createdOn(value: Date | null) { - this._data.createdOn = value; - } - - get createdBy(): string | null { - return this._data.createdBy; - } - - set createdBy(value: string | null) { - this._data.createdBy = value; - } - - get modifiedOn(): Date | null { - return this._data.modifiedOn; - } - - set modifiedOn(value: Date | null) { - this._data.modifiedOn = value; - } - - get modifiedBy(): string | null { - return this._data.modifiedBy; - } - - set modifiedBy(value: string | null) { - this._data.modifiedBy = value; + get identifier(): string | number { + return this._data.identifier; } get signature(): string | null { return this._data.signature; } - set signature(value: string | null) { - this._data.signature = value; + get created(): string | null { + return this._data.created; } - get data(): EventObject | TaskObject | JournalObject | null { - if (this._data.data instanceof EventObject || - this._data.data instanceof TaskObject || - this._data.data instanceof JournalObject) { - return this._data.data; + get modified(): string | null { + return this._data.modified; + } + + get properties(): EntityPropertiesObject { + if (this._data.properties instanceof EventObject || + this._data.properties instanceof TaskObject || + this._data.properties instanceof JournalObject) { + return this._data.properties; } - if (this._data.data) { - const type = this._data.data.type; - let hydrated; - if (type === 'task') { - hydrated = new TaskObject().fromJson(this._data.data as TaskInterface); - } else if (type === 'journal') { - hydrated = new JournalObject().fromJson(this._data.data as JournalInterface); - } else { - hydrated = new EventObject().fromJson(this._data.data as EventInterface); - } - this._data.data = hydrated; + if (this._data.properties) { + const hydrated = this.hydrateProperties(this._data.properties as EntityPropertiesData); + this._data.properties = hydrated; return hydrated; } - return null; + const defaultProperties = new EventObject(); + this._data.properties = defaultProperties; + return defaultProperties; } - set data(value: EventObject | TaskObject | JournalObject | null) { - this._data.data = value; + set properties(value: EntityPropertiesObject) { + this._data.properties = value; } } diff --git a/src/models/identity.ts b/src/models/identity.ts new file mode 100644 index 0000000..3291090 --- /dev/null +++ b/src/models/identity.ts @@ -0,0 +1,196 @@ +/** + * Identity implementation classes for Mail Manager services + */ + +import type { + ServiceIdentity, + ServiceIdentityNone, + ServiceIdentityBasic, + ServiceIdentityToken, + ServiceIdentityOAuth, + ServiceIdentityCertificate +} from '@/types/service'; + +/** + * Base Identity class + */ +export abstract class Identity { + abstract toJson(): ServiceIdentity; + + static fromJson(data: ServiceIdentity): Identity { + switch (data.type) { + case 'NA': + return IdentityNone.fromJson(data); + case 'BA': + return IdentityBasic.fromJson(data); + case 'TA': + return IdentityToken.fromJson(data); + case 'OA': + return IdentityOAuth.fromJson(data); + case 'CC': + return IdentityCertificate.fromJson(data); + default: + throw new Error(`Unknown identity type: ${(data as any).type}`); + } + } +} + +/** + * No authentication + */ +export class IdentityNone extends Identity { + readonly type = 'NA' as const; + + static fromJson(_data: ServiceIdentityNone): IdentityNone { + return new IdentityNone(); + } + + toJson(): ServiceIdentityNone { + return { + type: this.type + }; + } +} + +/** + * Basic authentication (username/password) + */ +export class IdentityBasic extends Identity { + readonly type = 'BA' as const; + identity: string; + secret: string; + + constructor(identity: string = '', secret: string = '') { + super(); + this.identity = identity; + this.secret = secret; + } + + static fromJson(data: ServiceIdentityBasic): IdentityBasic { + return new IdentityBasic(data.identity, data.secret); + } + + toJson(): ServiceIdentityBasic { + return { + type: this.type, + identity: this.identity, + secret: this.secret + }; + } +} + +/** + * Token authentication (API key, static token) + */ +export class IdentityToken extends Identity { + readonly type = 'TA' as const; + token: string; + + constructor(token: string = '') { + super(); + this.token = token; + } + + static fromJson(data: ServiceIdentityToken): IdentityToken { + return new IdentityToken(data.token); + } + + toJson(): ServiceIdentityToken { + return { + type: this.type, + token: this.token + }; + } +} + +/** + * OAuth authentication + */ +export class IdentityOAuth extends Identity { + readonly type = 'OA' as const; + accessToken: string; + accessScope?: string[]; + accessExpiry?: number; + refreshToken?: string; + refreshLocation?: string; + + constructor( + accessToken: string = '', + accessScope?: string[], + accessExpiry?: number, + refreshToken?: string, + refreshLocation?: string + ) { + super(); + this.accessToken = accessToken; + this.accessScope = accessScope; + this.accessExpiry = accessExpiry; + this.refreshToken = refreshToken; + this.refreshLocation = refreshLocation; + } + + static fromJson(data: ServiceIdentityOAuth): IdentityOAuth { + return new IdentityOAuth( + data.accessToken, + data.accessScope, + data.accessExpiry, + data.refreshToken, + data.refreshLocation + ); + } + + toJson(): ServiceIdentityOAuth { + return { + type: this.type, + accessToken: this.accessToken, + ...(this.accessScope && { accessScope: this.accessScope }), + ...(this.accessExpiry && { accessExpiry: this.accessExpiry }), + ...(this.refreshToken && { refreshToken: this.refreshToken }), + ...(this.refreshLocation && { refreshLocation: this.refreshLocation }) + }; + } + + isExpired(): boolean { + if (!this.accessExpiry) return false; + return Date.now() / 1000 >= this.accessExpiry; + } + + expiresIn(): number { + if (!this.accessExpiry) return Infinity; + return Math.max(0, this.accessExpiry - Date.now() / 1000); + } +} + +/** + * Client certificate authentication (mTLS) + */ +export class IdentityCertificate extends Identity { + readonly type = 'CC' as const; + certificate: string; + privateKey: string; + passphrase?: string; + + constructor(certificate: string = '', privateKey: string = '', passphrase?: string) { + super(); + this.certificate = certificate; + this.privateKey = privateKey; + this.passphrase = passphrase; + } + + static fromJson(data: ServiceIdentityCertificate): IdentityCertificate { + return new IdentityCertificate( + data.certificate, + data.privateKey, + data.passphrase + ); + } + + toJson(): ServiceIdentityCertificate { + return { + type: this.type, + certificate: this.certificate, + privateKey: this.privateKey, + ...(this.passphrase && { passphrase: this.passphrase }) + }; + } +} diff --git a/src/models/index.ts b/src/models/index.ts index 3d07bee..7d8e145 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,11 +1,22 @@ -/** - * Central export point for all Chrono Manager models - */ - -export { Collection } from './collection'; -export { Entity } from './entity'; -export { Event } from './event'; -export { Task } from './task'; -export { Journal } from './journal'; -export { Provider } from './provider'; -export { Service } from './service'; +export { CollectionObject } from './collection'; +export { EntityObject } from './entity'; +export { EventObject } from './event'; +export { TaskObject } from './task'; +export { JournalObject } from './journal'; +export { ProviderObject } from './provider'; +export { ServiceObject } from './service'; +export { + Identity, + IdentityNone, + IdentityBasic, + IdentityToken, + IdentityOAuth, + IdentityCertificate +} from './identity'; +export { + Location, + LocationUri, + LocationSocketSole, + LocationSocketSplit, + LocationFile +} from './location'; diff --git a/src/models/location.ts b/src/models/location.ts new file mode 100644 index 0000000..7e19cce --- /dev/null +++ b/src/models/location.ts @@ -0,0 +1,240 @@ +/** + * Location implementation classes for Mail Manager services + */ + +import type { + ServiceLocation, + ServiceLocationUri, + ServiceLocationSocketSole, + ServiceLocationSocketSplit, + ServiceLocationFile +} from '@/types/service'; + +/** + * Base Location class + */ +export abstract class Location { + abstract toJson(): ServiceLocation; + + static fromJson(data: ServiceLocation): Location { + switch (data.type) { + case 'URI': + return LocationUri.fromJson(data); + case 'SOCKET_SOLE': + return LocationSocketSole.fromJson(data); + case 'SOCKET_SPLIT': + return LocationSocketSplit.fromJson(data); + case 'FILE': + return LocationFile.fromJson(data); + default: + throw new Error(`Unknown location type: ${(data as any).type}`); + } + } +} + +/** + * URI-based service location for API and web services + * Used by: JMAP, Gmail API, etc. + */ +export class LocationUri extends Location { + readonly type = 'URI' as const; + scheme: string; + host: string; + port: number; + path?: string; + verifyPeer: boolean; + verifyHost: boolean; + + constructor( + scheme: string = 'https', + host: string = '', + port: number = 443, + path?: string, + verifyPeer: boolean = true, + verifyHost: boolean = true + ) { + super(); + this.scheme = scheme; + this.host = host; + this.port = port; + this.path = path; + this.verifyPeer = verifyPeer; + this.verifyHost = verifyHost; + } + + static fromJson(data: ServiceLocationUri): LocationUri { + return new LocationUri( + data.scheme, + data.host, + data.port, + data.path, + data.verifyPeer ?? true, + data.verifyHost ?? true + ); + } + + toJson(): ServiceLocationUri { + return { + type: this.type, + scheme: this.scheme, + host: this.host, + port: this.port, + ...(this.path && { path: this.path }), + ...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }), + ...(this.verifyHost !== undefined && { verifyHost: this.verifyHost }) + }; + } + + getUrl(): string { + const path = this.path || ''; + return `${this.scheme}://${this.host}:${this.port}${path}`; + } +} + +/** + * Single socket-based service location + * Used by: services using a single host/port combination + */ +export class LocationSocketSole extends Location { + readonly type = 'SOCKET_SOLE' as const; + host: string; + port: number; + encryption: 'none' | 'ssl' | 'tls' | 'starttls'; + verifyPeer: boolean; + verifyHost: boolean; + + constructor( + host: string = '', + port: number = 993, + encryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', + verifyPeer: boolean = true, + verifyHost: boolean = true + ) { + super(); + this.host = host; + this.port = port; + this.encryption = encryption; + this.verifyPeer = verifyPeer; + this.verifyHost = verifyHost; + } + + static fromJson(data: ServiceLocationSocketSole): LocationSocketSole { + return new LocationSocketSole( + data.host, + data.port, + data.encryption, + data.verifyPeer ?? true, + data.verifyHost ?? true + ); + } + + toJson(): ServiceLocationSocketSole { + return { + type: this.type, + host: this.host, + port: this.port, + encryption: this.encryption, + ...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }), + ...(this.verifyHost !== undefined && { verifyHost: this.verifyHost }) + }; + } +} + +/** + * Split socket-based service location + * Used by: traditional IMAP/SMTP configurations + */ +export class LocationSocketSplit extends Location { + readonly type = 'SOCKET_SPLIT' as const; + inboundHost: string; + inboundPort: number; + inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; + outboundHost: string; + outboundPort: number; + outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; + inboundVerifyPeer: boolean; + inboundVerifyHost: boolean; + outboundVerifyPeer: boolean; + outboundVerifyHost: boolean; + + constructor( + inboundHost: string = '', + inboundPort: number = 993, + inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', + outboundHost: string = '', + outboundPort: number = 465, + outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', + inboundVerifyPeer: boolean = true, + inboundVerifyHost: boolean = true, + outboundVerifyPeer: boolean = true, + outboundVerifyHost: boolean = true + ) { + super(); + this.inboundHost = inboundHost; + this.inboundPort = inboundPort; + this.inboundEncryption = inboundEncryption; + this.outboundHost = outboundHost; + this.outboundPort = outboundPort; + this.outboundEncryption = outboundEncryption; + this.inboundVerifyPeer = inboundVerifyPeer; + this.inboundVerifyHost = inboundVerifyHost; + this.outboundVerifyPeer = outboundVerifyPeer; + this.outboundVerifyHost = outboundVerifyHost; + } + + static fromJson(data: ServiceLocationSocketSplit): LocationSocketSplit { + return new LocationSocketSplit( + data.inboundHost, + data.inboundPort, + data.inboundEncryption, + data.outboundHost, + data.outboundPort, + data.outboundEncryption, + data.inboundVerifyPeer ?? true, + data.inboundVerifyHost ?? true, + data.outboundVerifyPeer ?? true, + data.outboundVerifyHost ?? true + ); + } + + toJson(): ServiceLocationSocketSplit { + return { + type: this.type, + inboundHost: this.inboundHost, + inboundPort: this.inboundPort, + inboundEncryption: this.inboundEncryption, + outboundHost: this.outboundHost, + outboundPort: this.outboundPort, + outboundEncryption: this.outboundEncryption, + ...(this.inboundVerifyPeer !== undefined && { inboundVerifyPeer: this.inboundVerifyPeer }), + ...(this.inboundVerifyHost !== undefined && { inboundVerifyHost: this.inboundVerifyHost }), + ...(this.outboundVerifyPeer !== undefined && { outboundVerifyPeer: this.outboundVerifyPeer }), + ...(this.outboundVerifyHost !== undefined && { outboundVerifyHost: this.outboundVerifyHost }) + }; + } +} + +/** + * File-based service location + * Used by: local file system providers + */ +export class LocationFile extends Location { + readonly type = 'FILE' as const; + path: string; + + constructor(path: string = '') { + super(); + this.path = path; + } + + static fromJson(data: ServiceLocationFile): LocationFile { + return new LocationFile(data.path); + } + + toJson(): ServiceLocationFile { + return { + type: this.type, + path: this.path + }; + } +} diff --git a/src/models/provider.ts b/src/models/provider.ts index ce22be6..8ba61dc 100644 --- a/src/models/provider.ts +++ b/src/models/provider.ts @@ -1,7 +1,11 @@ /** * Class model for Provider Interface */ -import type { ProviderCapabilitiesInterface, ProviderInterface } from "@/types/provider"; + +import type { + ProviderInterface, + ProviderCapabilitiesInterface +} from "@/types/provider"; export class ProviderObject implements ProviderInterface { @@ -9,8 +13,8 @@ export class ProviderObject implements ProviderInterface { constructor() { this._data = { - '@type': 'chrono:provider', - id: '', + '@type': 'chrono.provider', + identifier: '', label: '', capabilities: {}, }; @@ -25,16 +29,11 @@ export class ProviderObject implements ProviderInterface { return this._data; } - clone(): ProviderObject { - const cloned = new ProviderObject(); - cloned._data = JSON.parse(JSON.stringify(this._data)); - return cloned; + capable(capability: keyof ProviderCapabilitiesInterface): boolean { + const value = this._data.capabilities?.[capability]; + return value !== undefined && value !== false; } - capable(capability: keyof ProviderCapabilitiesInterface): boolean { - return !!(this._data.capabilities && this._data.capabilities[capability]); - } - capability(capability: keyof ProviderCapabilitiesInterface): any | null { if (this._data.capabilities) { return this._data.capabilities[capability]; @@ -48,8 +47,8 @@ export class ProviderObject implements ProviderInterface { return this._data['@type']; } - get id(): string { - return this._data.id; + get identifier(): string { + return this._data.identifier; } get label(): string { diff --git a/src/models/service.ts b/src/models/service.ts index 7766372..e6e2609 100644 --- a/src/models/service.ts +++ b/src/models/service.ts @@ -1,7 +1,15 @@ /** * Class model for Service Interface */ -import type { ServiceCapabilitiesInterface, ServiceInterface } from "@/types/service"; + +import type { + ServiceInterface, + ServiceCapabilitiesInterface, + ServiceIdentity, + ServiceLocation +} from "@/types/service"; +import { Identity } from './identity'; +import { Location } from './location'; export class ServiceObject implements ServiceInterface { @@ -11,10 +19,10 @@ export class ServiceObject implements ServiceInterface { this._data = { '@type': 'chrono:service', provider: '', - id: '', - label: '', - capabilities: {}, - enabled: true, + identifier: null, + label: null, + enabled: false, + capabilities: {} }; } @@ -27,16 +35,11 @@ export class ServiceObject implements ServiceInterface { return this._data; } - clone(): ServiceObject { - const cloned = new ServiceObject(); - cloned._data = JSON.parse(JSON.stringify(this._data)); - return cloned; + capable(capability: keyof ServiceCapabilitiesInterface): boolean { + const value = this._data.capabilities?.[capability]; + return value !== undefined && value !== false; } - capable(capability: keyof ServiceCapabilitiesInterface): boolean { - return !!(this._data.capabilities && this._data.capabilities[capability]); - } - capability(capability: keyof ServiceCapabilitiesInterface): any | null { if (this._data.capabilities) { return this._data.capabilities[capability]; @@ -54,18 +57,24 @@ export class ServiceObject implements ServiceInterface { return this._data.provider; } - get id(): string { - return this._data.id; - } - - get label(): string { - return this._data.label; + get identifier(): string | number | null { + return this._data.identifier; } get capabilities(): ServiceCapabilitiesInterface | undefined { return this._data.capabilities; } + /** Mutable Properties */ + + get label(): string | null { + return this._data.label; + } + + set label(value: string | null) { + this._data.label = value; + } + get enabled(): boolean { return this._data.enabled; } @@ -74,4 +83,46 @@ export class ServiceObject implements ServiceInterface { this._data.enabled = value; } + get location(): ServiceLocation | null { + return this._data.location ?? null; + } + + set location(value: ServiceLocation | null) { + this._data.location = value; + } + + get identity(): ServiceIdentity | null { + return this._data.identity ?? null; + } + + set identity(value: ServiceIdentity | null) { + this._data.identity = value; + } + + get auxiliary(): Record { + return this._data.auxiliary ?? {}; + } + + set auxiliary(value: Record) { + this._data.auxiliary = value; + } + + /** Helper Methods */ + + /** + * Get identity as a class instance for easier manipulation + */ + getIdentity(): Identity | null { + if (!this._data.identity) return null; + return Identity.fromJson(this._data.identity); + } + + /** + * Get location as a class instance for easier manipulation + */ + getLocation(): Location | null { + if (!this._data.location) return null; + return Location.fromJson(this._data.location); + } + } diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index 294cfe6..b255576 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -2,9 +2,7 @@ * Collection management service */ -import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core'; - -const fetchWrapper = createFetchWrapper(); +import { transceivePost } from './transceive'; import type { CollectionListRequest, CollectionListResponse, @@ -14,74 +12,119 @@ import type { CollectionFetchResponse, CollectionCreateRequest, CollectionCreateResponse, - CollectionModifyRequest, - CollectionModifyResponse, - CollectionDestroyRequest, - CollectionDestroyResponse, + CollectionUpdateResponse, + CollectionUpdateRequest, + CollectionDeleteResponse, + CollectionDeleteRequest, + CollectionInterface, } from '../types/collection'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; +import { CollectionObject, CollectionPropertiesObject } from '../models/collection'; -const BASE_URL = '/m/chrono_manager/collection'; +/** + * Helper to create the right collection model class based on provider identifier + * Uses provider-specific factory if available, otherwise returns base CollectionObject + */ +function createCollectionObject(data: CollectionInterface): CollectionObject { + const integrationStore = useIntegrationStore(); + const factoryItem = integrationStore.getItemById('mail_collection_factory', data.provider) as any; + const factory = factoryItem?.factory; + + // Use provider factory if available, otherwise base class + return factory ? factory(data) : new CollectionObject().fromJson(data); +} export const collectionService = { /** - * List all available collections + * Retrieve list of collections, optionally filtered by source selector * - * @param request - Collection list request parameters - * @returns Promise with collection list grouped by provider and service + * @param request - list request parameters + * + * @returns Promise with collection object list grouped by provider, service, and collection identifier */ - async list(request: CollectionListRequest = {}): Promise { - return await fetchWrapper.post(`${BASE_URL}/list`, request); + async list(request: CollectionListRequest = {}): Promise>>> { + const response = await transceivePost('collection.list', request); + + // Convert nested response to CollectionObject instances + const providerList: Record>> = {}; + Object.entries(response).forEach(([providerId, providerServices]) => { + const serviceList: Record> = {}; + Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => { + const collectionList: Record = {}; + Object.entries(serviceCollections).forEach(([collectionId, collectionData]) => { + collectionList[collectionId] = createCollectionObject(collectionData); + }); + serviceList[serviceId] = collectionList; + }); + providerList[providerId] = serviceList; + }); + + return providerList; }, /** - * Check which collections exist/are available + * Retrieve a specific collection by provider and identifier + * + * @param request - fetch request parameters + * + * @returns Promise with collection object + */ + async fetch(request: CollectionFetchRequest): Promise { + const response = await transceivePost('collection.fetch', request); + return createCollectionObject(response); + }, + + /** + * Retrieve collection availability status for a given source selector + * + * @param request - extant request parameters * - * @param request - Collection extant request with source selector * @returns Promise with collection availability status */ async extant(request: CollectionExtantRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/extant`, request); - }, - - /** - * Fetch a specific collection - * - * @param request - Collection fetch request - * @returns Promise with collection details - */ - async fetch(request: CollectionFetchRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/fetch`, request); + return await transceivePost('collection.extant', request); }, /** * Create a new collection * - * @param request - Collection create request - * @returns Promise with created collection + * @param request - create request parameters + * + * @returns Promise with created collection object */ - async create(request: CollectionCreateRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/create`, request); + async create(request: CollectionCreateRequest): Promise { + if (request.properties instanceof CollectionPropertiesObject) { + request.properties = request.properties.toJson(); + } + const response = await transceivePost('collection.create', request); + return createCollectionObject(response); }, /** - * Modify an existing collection + * Update an existing collection * - * @param request - Collection modify request - * @returns Promise with modified collection + * @param request - update request parameters + * + * @returns Promise with updated collection object */ - async modify(request: CollectionModifyRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/modify`, request); + async update(request: CollectionUpdateRequest): Promise { + if (request.properties instanceof CollectionPropertiesObject) { + request.properties = request.properties.toJson(); + } + const response = await transceivePost('collection.update', request); + return createCollectionObject(response); }, /** * Delete a collection * - * @param request - Collection destroy request + * @param request - delete request parameters + * * @returns Promise with deletion result */ - async destroy(request: CollectionDestroyRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/destroy`, request); + async delete(request: CollectionDeleteRequest): Promise { + return await transceivePost('collection.delete', request); }, }; diff --git a/src/services/entityService.ts b/src/services/entityService.ts index 6ba2387..f69051b 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -2,99 +2,149 @@ * Entity management service */ -import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core'; - -const fetchWrapper = createFetchWrapper(); +import { transceivePost } from './transceive'; import type { EntityListRequest, EntityListResponse, - EntityDeltaRequest, - EntityDeltaResponse, - EntityExtantRequest, - EntityExtantResponse, EntityFetchRequest, EntityFetchResponse, + EntityExtantRequest, + EntityExtantResponse, EntityCreateRequest, EntityCreateResponse, - EntityModifyRequest, - EntityModifyResponse, - EntityDestroyRequest, - EntityDestroyResponse, + EntityUpdateRequest, + EntityUpdateResponse, + EntityDeleteRequest, + EntityDeleteResponse, + EntityDeltaRequest, + EntityDeltaResponse, + EntityInterface, } from '../types/entity'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; +import { EntityObject } from '../models'; -const BASE_URL = '/m/chrono_manager/entity'; +/** + * Helper to create the right entity model class based on provider identifier + * Uses provider-specific factory if available, otherwise returns base EntityObject + */ +function createEntityObject(data: EntityInterface): EntityObject { + const integrationStore = useIntegrationStore(); + const factoryItem = integrationStore.getItemById('chrono_entity_factory', data.provider) as any; + const factory = factoryItem?.factory; + + // Use provider factory if available, otherwise base class + return factory ? factory(data) : new EntityObject().fromJson(data); +} export const entityService = { /** - * List all available entities (events, tasks, journals) + * Retrieve list of entities, optionally filtered by source selector * - * @param request - Entity list request parameters - * @returns Promise with entity list grouped by provider, service, and collection + * @param request - list request parameters + * + * @returns Promise with entity object list grouped by provider, service, collection, and entity identifier */ - async list(request: EntityListRequest = {}): Promise { - return await fetchWrapper.post(`${BASE_URL}/list`, request); + async list(request: EntityListRequest = {}): Promise>>>> { + const response = await transceivePost('entity.list', request); + + // Convert nested response to EntityObject instances + const providerList: Record>>> = {}; + Object.entries(response).forEach(([providerId, providerServices]) => { + const serviceList: Record>> = {}; + Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => { + const collectionList: Record> = {}; + Object.entries(serviceCollections).forEach(([collectionId, collectionEntities]) => { + const entityList: Record = {}; + Object.entries(collectionEntities).forEach(([entityId, entityData]) => { + entityList[entityId] = createEntityObject(entityData); + }); + collectionList[collectionId] = entityList; + }); + serviceList[serviceId] = collectionList; + }); + providerList[providerId] = serviceList; + }); + + return providerList; }, /** - * Get delta changes for entities + * Retrieve a specific entity by provider and identifier * - * @param request - Entity delta request with source selector - * @returns Promise with delta changes (created, modified, deleted) + * @param request - fetch request parameters + * + * @returns Promise with entity objects keyed by identifier */ - async delta(request: EntityDeltaRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/delta`, request); + async fetch(request: EntityFetchRequest): Promise> { + const response = await transceivePost('entity.fetch', request); + + // Convert response to EntityObject instances + const list: Record = {}; + Object.entries(response).forEach(([identifier, entityData]) => { + list[identifier] = createEntityObject(entityData); + }); + + return list; }, /** - * Check which entities exist/are available + * Retrieve entity availability status for a given source selector + * + * @param request - extant request parameters * - * @param request - Entity extant request with source selector * @returns Promise with entity availability status */ async extant(request: EntityExtantRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/extant`, request); - }, - - /** - * Fetch specific entities - * - * @param request - Entity fetch request - * @returns Promise with entity details - */ - async fetch(request: EntityFetchRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/fetch`, request); + return await transceivePost('entity.extant', request); }, /** * Create a new entity * - * @param request - Entity create request - * @returns Promise with created entity + * @param request - create request parameters + * + * @returns Promise with created entity object */ - async create(request: EntityCreateRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/create`, request); + async create(request: EntityCreateRequest): Promise { + const response = await transceivePost('entity.create', request); + return createEntityObject(response); }, /** - * Modify an existing entity + * Update an existing entity * - * @param request - Entity modify request - * @returns Promise with modified entity + * @param request - update request parameters + * + * @returns Promise with updated entity object */ - async modify(request: EntityModifyRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/modify`, request); + async update(request: EntityUpdateRequest): Promise { + const response = await transceivePost('entity.update', request); + return createEntityObject(response); }, /** * Delete an entity * - * @param request - Entity destroy request + * @param request - delete request parameters + * * @returns Promise with deletion result */ - async destroy(request: EntityDestroyRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/destroy`, request); + async delete(request: EntityDeleteRequest): Promise { + return await transceivePost('entity.delete', request); }, + + /** + * Retrieve delta changes for entities + * + * @param request - delta request parameters + * + * @returns Promise with delta changes (created, modified, deleted) + */ + async delta(request: EntityDeltaRequest): Promise { + return await transceivePost('entity.delta', request); + }, + }; export default entityService; diff --git a/src/services/index.ts b/src/services/index.ts index f713c00..66fbd58 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,16 +1,4 @@ -/** - * Central export point for all Chrono Manager services - */ - -// Services export { providerService } from './providerService'; export { serviceService } from './serviceService'; export { collectionService } from './collectionService'; export { entityService } from './entityService'; - -// Type exports -export type * from '../types/common'; -export type * from '../types/provider'; -export type * from '../types/service'; -export type * from '../types/collection'; -export type * from '../types/entity'; diff --git a/src/services/providerService.ts b/src/services/providerService.ts index 375b0e1..91d7a05 100644 --- a/src/services/providerService.ts +++ b/src/services/providerService.ts @@ -2,34 +2,74 @@ * Provider management service */ -import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core'; +import type { + ProviderListRequest, + ProviderListResponse, + ProviderExtantRequest, + ProviderExtantResponse, + ProviderFetchRequest, + ProviderFetchResponse, + ProviderInterface, +} from '../types/provider'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; +import { transceivePost } from './transceive'; +import { ProviderObject } from '../models/provider'; -const fetchWrapper = createFetchWrapper(); -import type { ProviderListResponse, ProviderExtantResponse } from '../types/provider'; -import type { SourceSelector } from '../types/common'; - -const BASE_URL = '/m/chrono_manager/provider'; +/** + * Helper to create the right provider model class based on provider identifier + * Uses provider-specific factory if available, otherwise returns base ProviderObject + */ +function createProviderObject(data: ProviderInterface): ProviderObject { + const integrationStore = useIntegrationStore(); + const factoryItem = integrationStore.getItemById('chrono_provider_factory', data.identifier) as any; + const factory = factoryItem?.factory; + + // Use provider factory if available, otherwise base class + return factory ? factory(data) : new ProviderObject().fromJson(data); +} export const providerService = { /** - * List all available providers + * Retrieve list of providers, optionally filtered by source selector * - * @returns Promise with provider list keyed by provider ID + * @param request - list request parameters + * + * @returns Promise with provider object list keyed by provider identifier */ - async list(): Promise { - return await fetchWrapper.get(`${BASE_URL}/list`); + async list(request: ProviderListRequest = {}): Promise> { + const response = await transceivePost('provider.list', request); + + // Convert response to ProviderObject instances + const list: Record = {}; + Object.entries(response).forEach(([providerId, providerData]) => { + list[providerId] = createProviderObject(providerData); + }); + + return list; }, /** - * Check which providers exist/are available + * Retrieve specific provider by identifier * - * @param sources - Source selector with provider IDs to check + * @param request - fetch request parameters + * + * @returns Promise with provider object + */ + async fetch(request: ProviderFetchRequest): Promise { + const response = await transceivePost('provider.fetch', request); + return createProviderObject(response); + }, + + /** + * Retrieve provider availability status for a given source selector + * + * @param request - extant request parameters * * @returns Promise with provider availability status */ - async extant(sources: SourceSelector): Promise { - return await fetchWrapper.post(`${BASE_URL}/extant`, { sources }); + async extant(request: ProviderExtantRequest): Promise { + return await transceivePost('provider.extant', request); }, }; diff --git a/src/services/serviceService.ts b/src/services/serviceService.ts index 4460e45..940b12a 100644 --- a/src/services/serviceService.ts +++ b/src/services/serviceService.ts @@ -2,50 +2,161 @@ * Service management service */ -import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core'; - -const fetchWrapper = createFetchWrapper(); import type { ServiceListRequest, ServiceListResponse, - ServiceExtantRequest, - ServiceExtantResponse, ServiceFetchRequest, ServiceFetchResponse, + ServiceExtantRequest, + ServiceExtantResponse, + ServiceCreateResponse, + ServiceCreateRequest, + ServiceUpdateResponse, + ServiceUpdateRequest, + ServiceDeleteResponse, + ServiceDeleteRequest, + ServiceDiscoverRequest, + ServiceDiscoverResponse, + ServiceTestRequest, + ServiceTestResponse, + ServiceInterface, } from '../types/service'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; +import { transceivePost } from './transceive'; +import { ServiceObject } from '../models/service'; -const BASE_URL = '/m/chrono_manager/service'; +/** + * Helper to create the right service model class based on provider identifier + * Uses provider-specific factory if available, otherwise returns base ServiceObject + */ +function createServiceObject(data: ServiceInterface): ServiceObject { + const integrationStore = useIntegrationStore(); + const factoryItem = integrationStore.getItemById('chrono_service_factory', data.provider) as any; + const factory = factoryItem?.factory; + + // Use provider factory if available, otherwise base class + return factory ? factory(data) : new ServiceObject().fromJson(data); +} export const serviceService = { /** - * List all available services + * Retrieve list of services, optionally filtered by source selector * - * @param request - Service list request parameters - * @returns Promise with service list grouped by provider + * @param request - list request parameters + * + * @returns Promise with service object list grouped by provider and keyed by service identifier */ - async list(request: ServiceListRequest = {}): Promise { - return await fetchWrapper.post(`${BASE_URL}/list`, request); + async list(request: ServiceListRequest = {}): Promise>> { + const response = await transceivePost('service.list', request); + + // Convert nested response to ServiceObject instances + const providerList: Record> = {}; + Object.entries(response).forEach(([providerId, providerServices]) => { + const serviceList: Record = {}; + Object.entries(providerServices).forEach(([serviceId, serviceData]) => { + serviceList[serviceId] = createServiceObject(serviceData); + }); + providerList[providerId] = serviceList; + }); + + return providerList; }, /** - * Check which services exist/are available + * Retrieve a specific service by provider and identifier + * + * @param request - fetch request parameters + * + * @returns Promise with service object + */ + async fetch(request: ServiceFetchRequest): Promise { + const response = await transceivePost('service.fetch', request); + return createServiceObject(response); + }, + + /** + * Retrieve service availability status for a given source selector + * + * @param request - extant request parameters * - * @param request - Service extant request with source selector * @returns Promise with service availability status */ async extant(request: ServiceExtantRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/extant`, request); + return await transceivePost('service.extant', request); }, /** - * Fetch a specific service + * Retrieve discoverable services for a given source selector, sorted by provider * - * @param request - Service fetch request with provider and service IDs - * @returns Promise with service details + * @param request - discover request parameters + * + * @returns Promise with array of discovered services sorted by provider */ - async fetch(request: ServiceFetchRequest): Promise { - return await fetchWrapper.post(`${BASE_URL}/fetch`, request); + async discover(request: ServiceDiscoverRequest): Promise { + const response = await transceivePost('service.discover', request); + + // Convert discovery results to ServiceObjects + const services: ServiceObject[] = []; + Object.entries(response).forEach(([providerId, location]) => { + const serviceData: ServiceInterface = { + '@type': 'chrono:service', + provider: providerId, + identifier: null, + label: null, + enabled: false, + location: location, + }; + services.push(createServiceObject(serviceData)); + }); + + // Sort by provider + return services.sort((a, b) => a.provider.localeCompare(b.provider)); + }, + + /** + * Test service connectivity and configuration + * + * @param request - Service test request + * @returns Promise with test results + */ + async test(request: ServiceTestRequest): Promise { + return await transceivePost('service.test', request); + }, + + /** + * Create a new service + * + * @param request - create request parameters + * + * @returns Promise with created service object + */ + async create(request: ServiceCreateRequest): Promise { + const response = await transceivePost('service.create', request); + return createServiceObject(response); + }, + + /** + * Update a existing service + * + * @param request - update request parameters + * + * @returns Promise with updated service object + */ + async update(request: ServiceUpdateRequest): Promise { + const response = await transceivePost('service.update', request); + return createServiceObject(response); + }, + + /** + * Delete a service + * + * @param request - delete request parameters + * + * @returns Promise with deletion result + */ + async delete(request: { provider: string; identifier: string | number }): Promise { + return await transceivePost('service.delete', request); }, }; diff --git a/src/services/transceive.ts b/src/services/transceive.ts new file mode 100644 index 0000000..430775e --- /dev/null +++ b/src/services/transceive.ts @@ -0,0 +1,50 @@ +/** + * API Client for Chrono Manager + * Provides a centralized way to make API calls with envelope wrapping/unwrapping + */ + +import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core'; +import type { ApiRequest, ApiResponse } from '../types/common'; + +const fetchWrapper = createFetchWrapper(); +const API_URL = '/m/chrono_manager/v1'; +const API_VERSION = 1; + +/** + * Generate a unique transaction ID + */ +export function generateTransactionId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Make an API call with automatic envelope wrapping and unwrapping + * + * @param operation - Operation name (e.g., 'provider.list', 'service.autodiscover') + * @param data - Operation-specific request data + * @param user - Optional user identifier override + * @returns Promise with unwrapped response data + * @throws Error if the API returns an error status + */ +export async function transceivePost( + operation: string, + data: TRequest, + user?: string +): Promise { + const request: ApiRequest = { + version: API_VERSION, + transaction: generateTransactionId(), + operation, + data, + user + }; + + const response: ApiResponse = await fetchWrapper.post(API_URL, request); + + if (response.status === 'error') { + const errorMessage = `[${operation}] ${response.data.message}${response.data.code ? ` (code: ${response.data.code})` : ''}`; + throw new Error(errorMessage); + } + + return response.data; +} diff --git a/src/stores/collectionsStore.ts b/src/stores/collectionsStore.ts index 25c4600..82b1aa1 100644 --- a/src/stores/collectionsStore.ts +++ b/src/stores/collectionsStore.ts @@ -1,202 +1,307 @@ /** - * Chrono Manager - Collections Store + * Collections Store */ -import { defineStore } from 'pinia'; -import { ref } from 'vue'; -import { collectionService } from '../services/collectionService'; -import type { - SourceSelector, - ListFilter, - ListSort, -} from '../types/common'; -import { CollectionObject } from '../models/collection'; -import type { ServiceObject } from '../models/service'; -import type { CollectionInterface } from '../types/collection'; +import { ref, computed, readonly } from 'vue' +import { defineStore } from 'pinia' +import { collectionService } from '../services' +import { CollectionObject, CollectionPropertiesObject } from '../models/collection' +import type { SourceSelector, ListFilter, ListSort } from '../types' -export const useCollectionsStore = defineStore('chronoCollectionsStore', () => { +export const useCollectionsStore = defineStore('mailCollectionsStore', () => { // State - const collections = ref([]); - - // Actions + const _collections = ref>({}) + const transceiving = ref(false) /** - * Retrieve collections from the server + * Get count of collections in store */ - async function list( - sources?: SourceSelector, - filter?: ListFilter, - sort?: ListSort, - uid?: string - ): Promise { - try { - const response = await collectionService.list({ sources, filter, sort, uid }); - - // Flatten the nested response into a flat array - const flatCollections: CollectionObject[] = []; - Object.entries(response).forEach(([_providerId, providerCollections]) => { - Object.entries(providerCollections).forEach(([_serviceId, serviceCollections]) => { - Object.values(serviceCollections).forEach((collection: CollectionInterface) => { - flatCollections.push(new CollectionObject().fromJson(collection)); - }); - }); - }); - - console.debug('[Chrono Manager](Store) - Successfully retrieved', flatCollections.length, 'collections:', flatCollections.map(c => ({ - id: c.id, - label: c.label, - service: c.service, - provider: c.provider - }))); - - collections.value = flatCollections; - return flatCollections; - } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to retrieve collections:', error); - throw error; - } - } + const count = computed(() => Object.keys(_collections.value).length) /** - * Fetch a specific collection + * Check if any collections are present in store */ - async function fetch( - provider: string, - service: string, - identifier: string | number, - uid?: string - ): Promise { - try { - const response = await collectionService.fetch({ provider, service, identifier, uid }); - - return new CollectionObject().fromJson(response); - } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to fetch collection:', error); - throw error; - } - } + const has = computed(() => count.value > 0) /** - * Create a fresh collection object with default values + * Get all collections present in store */ - function fresh(): CollectionObject { - return new CollectionObject(); - } + const collections = computed(() => Object.values(_collections.value)) /** - * Create a new collection + * Get all collections present in store grouped by service */ - async function create( - service: ServiceObject, - collection: CollectionObject, - options?: string[], - uid?: string - ): Promise { - try { - if (service.provider === null || service.id === null) { - throw new Error('Invalid service object, must have a provider and identifier'); + const collectionsByService = computed(() => { + const groups: Record = {} + + Object.values(_collections.value).forEach((collection) => { + const serviceKey = `${collection.provider}:${collection.service}` + if (!groups[serviceKey]) { + groups[serviceKey] = [] } + groups[serviceKey].push(collection) + }) + + return groups + }) - const response = await collectionService.create({ - provider: service.provider, - service: service.id, - data: collection.toJson(), - options, - uid - }); - - const createdCollection = new CollectionObject().fromJson(response); - collections.value.push(createdCollection); - - console.debug('[Chrono Manager](Store) - Successfully created collection'); - - return createdCollection; - } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to create collection:', error); - throw error; + /** + * Get a specific collection from store, with optional retrieval + * + * @param provider - provider identifier + * @param service - service identifier + * @param identifier - collection identifier + * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only + * + * @returns Collection object or null + */ + function collection(provider: string, service: string | number, identifier: string | number, retrieve: boolean = false): CollectionObject | null { + const key = identifierKey(provider, service, identifier) + if (retrieve === true && !_collections.value[key]) { + console.debug(`[Chrono Manager][Store] - Force fetching collection "${key}"`) + fetch(provider, service, identifier) } + + return _collections.value[key] || null } /** - * Modify an existing collection + * Get all collections for a specific service + * + * @param provider - provider identifier + * @param service - service identifier + * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only + * + * @returns Array of collection objects */ - async function modify( - collection: CollectionObject, - uid?: string - ): Promise { - try { - if (!collection.provider || !collection.service || !collection.id) { - throw new Error('Collection must have provider, service, and id'); - } - - const response = await collectionService.modify({ - provider: collection.provider, - service: collection.service, - identifier: collection.id, - data: collection.toJson(), - uid - }); - - const modifiedCollection = new CollectionObject().fromJson(response); - const index = collections.value.findIndex(c => c.id === collection.id); - if (index !== -1) { - collections.value[index] = modifiedCollection; - } - - console.debug('[Chrono Manager](Store) - Successfully modified collection'); - - return modifiedCollection; - } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to modify collection:', error); - throw error; - } - } - - /** - * Delete a collection - */ - async function destroy( - collection: CollectionObject, - uid?: string - ): Promise { - try { - if (!collection.provider || !collection.service || !collection.id) { - throw new Error('Collection must have provider, service, and id'); - } - - const response = await collectionService.destroy({ - provider: collection.provider, - service: collection.service, - identifier: collection.id, - uid - }); - - if (response.success) { - const index = collections.value.findIndex(c => c.id === collection.id); - if (index !== -1) { - collections.value.splice(index, 1); + function collectionsForService(provider: string, service: string | number, retrieve: boolean = false): CollectionObject[] { + const serviceKeyPrefix = `${provider}:${service}:` + const serviceCollections = Object.entries(_collections.value) + .filter(([key]) => key.startsWith(serviceKeyPrefix)) + .map(([_, collection]) => collection) + + if (retrieve === true && serviceCollections.length === 0) { + console.debug(`[Chrono Manager][Store] - Force fetching collections for service "${provider}:${service}"`) + const sources: SourceSelector = { + [provider]: { + [String(service)]: true } } - - console.debug('[Chrono Manager](Store) - Successfully destroyed collection'); - - return response.success; + list(sources) + } + + return serviceCollections + } + + /** + * Create unique key for a collection + */ + function identifierKey(provider: string, service: string | number | null, identifier: string | number | null): string { + return `${provider}:${service ?? ''}:${identifier ?? ''}` + } + + // Actions + + /** + * Retrieve all or specific collections, optionally filtered by source selector + * + * @param sources - optional source selector + * @param filter - optional list filter + * @param sort - optional list sort + * + * @returns Promise with collection object list keyed by provider, service, and collection identifier + */ + async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort): Promise> { + transceiving.value = true + try { + const response = await collectionService.list({ sources, filter, sort }) + + // Flatten nested structure: provider:service:collection -> "provider:service:collection": object + const collections: Record = {} + Object.entries(response).forEach(([_providerId, providerServices]) => { + Object.entries(providerServices).forEach(([_serviceId, serviceCollections]) => { + Object.entries(serviceCollections).forEach(([_collectionId, collectionObj]) => { + const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier) + collections[key] = collectionObj + }) + }) + }) + + // Merge retrieved collections into state + _collections.value = { ..._collections.value, ...collections } + + console.debug('[Chrono Manager][Store] - Successfully retrieved', Object.keys(collections).length, 'collections') + return collections } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to destroy collection:', error); - throw error; + console.error('[Chrono Manager][Store] - Failed to retrieve collections:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Retrieve a specific collection by provider, service, and identifier + * + * @param provider - provider identifier + * @param service - service identifier + * @param identifier - collection identifier + * + * @returns Promise with collection object + */ + async function fetch(provider: string, service: string | number, identifier: string | number): Promise { + transceiving.value = true + try { + const response = await collectionService.fetch({ provider, service, collection: identifier }) + + // Merge fetched collection into state + const key = identifierKey(response.provider, response.service, response.identifier) + _collections.value[key] = response + + console.debug('[Chrono Manager][Store] - Successfully fetched collection:', key) + return response + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to fetch collection:', error) + throw error + } finally { + transceiving.value = false } } - return { - // State - collections, + /** + * Retrieve collection availability status for a given source selector + * + * @param sources - source selector to check availability for + * + * @returns Promise with collection availability status + */ + async function extant(sources: SourceSelector) { + transceiving.value = true + try { + const response = await collectionService.extant({ sources }) + + console.debug('[Chrono Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'collections') + return response + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to check collections:', error) + throw error + } finally { + transceiving.value = false + } + } + /** + * Create a new collection with given provider, service, and data + * + * @param provider - provider identifier for the new collection + * @param service - service identifier for the new collection + * @param collection - optional parent collection identifier + * @param data - collection properties for creation + * + * @returns Promise with created collection object + */ + async function create(provider: string, service: string | number, collection: string | number | null, data: CollectionPropertiesObject): Promise { + transceiving.value = true + try { + const response = await collectionService.create({ + provider, + service, + collection, + properties: data + }) + + // Merge created collection into state + const key = identifierKey(response.provider, response.service, response.identifier) + _collections.value[key] = response + + console.debug('[Chrono Manager][Store] - Successfully created collection:', key) + return response + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to create collection:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Update an existing collection with given provider, service, identifier, and data + * + * @param provider - provider identifier for the collection to update + * @param service - service identifier for the collection to update + * @param identifier - collection identifier for the collection to update + * @param data - collection properties for update + * + * @returns Promise with updated collection object + */ + async function update(provider: string, service: string | number, identifier: string | number, data: CollectionPropertiesObject): Promise { + transceiving.value = true + try { + const response = await collectionService.update({ + provider, + service, + identifier, + properties: data + }) + + // Merge updated collection into state + const key = identifierKey(response.provider, response.service, response.identifier) + _collections.value[key] = response + + console.debug('[Chrono Manager][Store] - Successfully updated collection:', key) + return response + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to update collection:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Delete a collection by provider, service, and identifier + * + * @param provider - provider identifier for the collection to delete + * @param service - service identifier for the collection to delete + * @param identifier - collection identifier for the collection to delete + * + * @returns Promise with deletion result + */ + async function remove(provider: string, service: string | number, identifier: string | number): Promise { + transceiving.value = true + try { + await collectionService.delete({ provider, service, identifier }) + + // Remove deleted collection from state + const key = identifierKey(provider, service, identifier) + delete _collections.value[key] + + console.debug('[Chrono Manager][Store] - Successfully deleted collection:', key) + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to delete collection:', error) + throw error + } finally { + transceiving.value = false + } + } + + // Return public API + return { + // State (readonly) + transceiving: readonly(transceiving), + // Getters + count, + has, + collections, + collectionsByService, + collectionsForService, // Actions + collection, list, fetch, - fresh, + extant, create, - modify, - destroy, - }; -}); + update, + delete: remove, + } +}) diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts index 3d79dbf..7a4a501 100644 --- a/src/stores/entitiesStore.ts +++ b/src/stores/entitiesStore.ts @@ -1,281 +1,346 @@ /** - * Chrono Manager - Entities Store + * Entities Store */ -import { defineStore } from 'pinia'; -import { ref } from 'vue'; -import { entityService } from '../services/entityService'; -import { EntityObject } from '../models/entity'; -import { EventObject } from '../models/event'; -import { TaskObject } from '../models/task'; -import { JournalObject } from '../models/journal'; -import { CollectionObject } from '../models/collection'; -import type { - SourceSelector, - ListFilter, - ListSort, - ListRange, -} from '../types/common'; -import type { - EntityInterface, -} from '../types/entity'; +import { ref, computed, readonly } from 'vue' +import { defineStore } from 'pinia' +import { entityService } from '../services' +import { EntityObject } from '../models' +import type { SourceSelector, ListFilter, ListSort, ListRange } from '../types/common' export const useEntitiesStore = defineStore('chronoEntitiesStore', () => { // State - const entities = ref([]); + const _entities = ref>({}) + const transceiving = ref(false) + + /** + * Get count of entities in store + */ + const count = computed(() => Object.keys(_entities.value).length) + + /** + * Check if any entities are present in store + */ + const has = computed(() => count.value > 0) + + /** + * Get all entities present in store + */ + const entities = computed(() => Object.values(_entities.value)) + + /** + * Get a specific entity from store, with optional retrieval + * + * @param provider - provider identifier + * @param service - service identifier + * @param collection - collection identifier + * @param identifier - entity identifier + * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only + * + * @returns Entity object or null + */ + function entity(provider: string, service: string | number, collection: string | number, identifier: string | number, retrieve: boolean = false): EntityObject | null { + const key = identifierKey(provider, service, collection, identifier) + if (retrieve === true && !_entities.value[key]) { + console.debug(`[Chrono Manager][Store] - Force fetching entity "${key}"`) + fetch(provider, service, collection, [identifier]) + } + + return _entities.value[key] || null + } + + /** + * Get all entities for a specific collection + * + * @param provider - provider identifier + * @param service - service identifier + * @param collection - collection identifier + * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only + * + * @returns Array of entity objects + */ + function entitiesForCollection(provider: string, service: string | number, collection: string | number, retrieve: boolean = false): EntityObject[] { + const collectionKeyPrefix = `${provider}:${service}:${collection}:` + const collectionEntities = Object.entries(_entities.value) + .filter(([key]) => key.startsWith(collectionKeyPrefix)) + .map(([_, entity]) => entity) + + if (retrieve === true && collectionEntities.length === 0) { + console.debug(`[Chrono Manager][Store] - Force fetching entities for collection "${provider}:${service}:${collection}"`) + const sources: SourceSelector = { + [provider]: { + [String(service)]: { + [String(collection)]: true + } + } + } + list(sources) + } + + return collectionEntities + } + + /** + * Create unique key for an entity + */ + function identifierKey(provider: string, service: string | number, collection: string | number, identifier: string | number): string { + return `${provider}:${service}:${collection}:${identifier}` + } // Actions - + /** - * Reset the store to initial state + * Retrieve all or specific entities, optionally filtered by source selector + * + * @param sources - optional source selector + * @param filter - optional list filter + * @param sort - optional list sort + * @param range - optional list range + * + * @returns Promise with entity object list keyed by identifier */ - function reset(): void { - entities.value = []; - } - - /** - * List entities for all or specific collection - */ - async function list( - provider: string | null, - service: string | null, - collection: string | number | null, - filter?: ListFilter, - sort?: ListSort, - range?: ListRange, - uid?: string - ): Promise { + async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise> { + transceiving.value = true try { - // Validate hierarchical requirements - if (collection !== null && (service === null || provider === null)) { - throw new Error('Collection requires both service and provider'); - } - if (service !== null && provider === null) { - throw new Error('Service requires provider'); - } + const response = await entityService.list({ sources, filter, sort, range }) - // Build sources object level by level - const sources: SourceSelector = {}; - if (provider !== null) { - if (service !== null) { - if (collection !== null) { - sources[provider] = { [service]: { [collection]: true } }; - } else { - sources[provider] = { [service]: true }; - } - } else { - sources[provider] = true; - } - } + // Flatten nested structure: provider:service:collection:entity -> "provider:service:collection:entity": object + const entities: Record = {} + Object.entries(response).forEach(([providerId, providerServices]) => { + Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => { + Object.entries(serviceCollections).forEach(([collectionId, collectionEntities]) => { + Object.entries(collectionEntities).forEach(([entityId, entityData]) => { + const key = identifierKey(providerId, serviceId, collectionId, entityId) + entities[key] = entityData + }) + }) + }) + }) - // Transmit - const response = await entityService.list({ sources, filter, sort, range, uid }); - - // Flatten the nested response into a flat array - const flatEntities: EntityObject[] = []; - Object.entries(response).forEach(([, providerEntities]) => { - Object.entries(providerEntities).forEach(([, serviceEntities]) => { - Object.entries(serviceEntities).forEach(([, collectionEntities]) => { - Object.values(collectionEntities).forEach((entity: EntityInterface) => { - flatEntities.push(new EntityObject().fromJson(entity)); - }); - }); - }); - }); - - console.debug('[Chrono Manager](Store) - Successfully retrieved', flatEntities.length, 'entities'); - - entities.value = flatEntities; - return flatEntities; + // Merge retrieved entities into state + _entities.value = { ..._entities.value, ...entities } + + console.debug('[Chrono Manager][Store] - Successfully retrieved', Object.keys(entities).length, 'entities') + return entities } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to retrieve entities:', error); - throw error; + console.error('[Chrono Manager][Store] - Failed to retrieve entities:', error) + throw error + } finally { + transceiving.value = false } } - + /** - * Fetch entities for a specific collection + * Retrieve specific entities by provider, service, collection, and identifiers + * + * @param provider - provider identifier + * @param service - service identifier + * @param collection - collection identifier + * @param identifiers - array of entity identifiers to fetch + * + * @returns Promise with entity objects keyed by identifier */ - async function fetch( - collection: CollectionObject, - identifiers: (string | number)[], - uid?: string - ): Promise { + async function fetch(provider: string, service: string | number, collection: string | number, identifiers: (string | number)[]): Promise> { + transceiving.value = true try { - if (!collection.provider || !collection.service || !collection.id) { - throw new Error('Collection must have provider, service, and id'); - } - - const response = await entityService.fetch({ - provider: collection.provider, - service: collection.service, - collection: collection.id, - identifiers, - uid - }); + const response = await entityService.fetch({ provider, service, collection, identifiers }) - return Object.values(response).map(entity => new EntityObject().fromJson(entity)); + // Merge fetched entities into state + const entities: Record = {} + Object.entries(response).forEach(([identifier, entityData]) => { + const key = identifierKey(provider, service, collection, identifier) + entities[key] = entityData + _entities.value[key] = entityData + }) + + console.debug('[Chrono Manager][Store] - Successfully fetched', Object.keys(entities).length, 'entities') + return entities } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to fetch entities:', error); - throw error; + console.error('[Chrono Manager][Store] - Failed to fetch entities:', error) + throw error + } finally { + transceiving.value = false } } /** - * Create a fresh entity object + * Retrieve entity availability status for a given source selector + * + * @param sources - source selector to check availability for + * + * @returns Promise with entity availability status */ - function fresh(type: string): EntityObject { - const entity = new EntityObject(); - - if (type === 'event') { - entity.data = new EventObject(); - } else if (type === 'task') { - entity.data = new TaskObject(); - } else if (type === 'journal') { - entity.data = new JournalObject(); - } else { - entity.data = new EventObject(); - } - - if (entity.data) { - entity.data.created = new Date(); - } - - return entity; - } - - /** - * Create a new entity - */ - async function create( - collection: CollectionObject, - entity: EntityObject, - options?: string[], - uid?: string - ): Promise { + async function extant(sources: SourceSelector) { + transceiving.value = true try { - if (!collection.provider || !collection.service || !collection.id) { - throw new Error('Collection must have provider, service, and id'); - } - - const response = await entityService.create({ - provider: collection.provider, - service: collection.service, - collection: collection.id, - data: entity.toJson(), - options, - uid - }); - - const createdEntity = new EntityObject().fromJson(response); - entities.value.push(createdEntity); - - console.debug('[Chrono Manager](Store) - Successfully created entity'); - - return createdEntity; + const response = await entityService.extant({ sources }) + console.debug('[Chrono Manager][Store] - Successfully checked entity availability') + return response } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to create entity:', error); - throw error; + console.error('[Chrono Manager][Store] - Failed to check entity availability:', error) + throw error + } finally { + transceiving.value = false } } /** - * Modify an existing entity + * Create a new entity with given provider, service, collection, and data + * + * @param provider - provider identifier for the new entity + * @param service - service identifier for the new entity + * @param collection - collection identifier for the new entity + * @param data - entity properties for creation + * + * @returns Promise with created entity object */ - async function modify( - collection: CollectionObject, - entity: EntityObject, - uid?: string - ): Promise { + async function create(provider: string, service: string | number, collection: string | number, data: any): Promise { + transceiving.value = true try { - if (!collection.provider || !collection.service || !collection.id) { - throw new Error('Collection must have provider, service, and id'); - } - if (!entity.in || !entity.id) { - throw new Error('Invalid entity object, must have an collection and entity identifier'); - } - if (collection.id !== entity.in) { - throw new Error('Invalid entity object, does not belong to the specified collection'); - } + const response = await entityService.create({ provider, service, collection, properties: data }) + + // Add created entity to state + const key = identifierKey(response.provider, response.service, response.collection, response.identifier) + _entities.value[key] = response - const response = await entityService.modify({ - provider: collection.provider, - service: collection.service, - collection: collection.id, - identifier: entity.id, - data: entity.toJson(), - uid - }); - - const modifiedEntity = new EntityObject().fromJson(response); - const index = entities.value.findIndex(e => e.id === entity.id); - if (index !== -1) { - entities.value[index] = modifiedEntity; - } - - console.debug('[Chrono Manager](Store) - Successfully modified entity'); - - return modifiedEntity; + console.debug('[Chrono Manager][Store] - Successfully created entity:', key) + return response } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to modify entity:', error); - throw error; + console.error('[Chrono Manager][Store] - Failed to create entity:', error) + throw error + } finally { + transceiving.value = false } } /** - * Delete an entity + * Update an existing entity with given provider, service, collection, identifier, and data + * + * @param provider - provider identifier for the entity to update + * @param service - service identifier for the entity to update + * @param collection - collection identifier for the entity to update + * @param identifier - entity identifier for the entity to update + * @param data - entity properties for update + * + * @returns Promise with updated entity object */ - async function destroy( - collection: CollectionObject, - entity: EntityObject, - uid?: string - ): Promise { + async function update(provider: string, service: string | number, collection: string | number, identifier: string | number, data: any): Promise { + transceiving.value = true try { - if (!collection.provider || !collection.service || !collection.id) { - throw new Error('Collection must have provider, service, and id'); - } - if (!entity.in || !entity.id) { - throw new Error('Invalid entity object, must have an collection and entity identifier'); - } - if (collection.id !== entity.in) { - throw new Error('Invalid entity object, does not belong to the specified collection'); - } + const response = await entityService.update({ provider, service, collection, identifier, properties: data }) + + // Update entity in state + const key = identifierKey(response.provider, response.service, response.collection, response.identifier) + _entities.value[key] = response - const response = await entityService.destroy({ - provider: collection.provider, - service: collection.service, - collection: collection.id, - identifier: entity.id, - uid - }); - - if (response.success) { - const index = entities.value.findIndex(e => e.id === entity.id); - if (index !== -1) { - entities.value.splice(index, 1); - } - } - - console.debug('[Chrono Manager](Store) - Successfully destroyed entity'); - - return response.success; + console.debug('[Chrono Manager][Store] - Successfully updated entity:', key) + return response } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to destroy entity:', error); - throw error; + console.error('[Chrono Manager][Store] - Failed to update entity:', error) + throw error + } finally { + transceiving.value = false } } + /** + * Delete an entity by provider, service, collection, and identifier + * + * @param provider - provider identifier for the entity to delete + * @param service - service identifier for the entity to delete + * @param collection - collection identifier for the entity to delete + * @param identifier - entity identifier for the entity to delete + * + * @returns Promise with deletion result + */ + async function remove(provider: string, service: string | number, collection: string | number, identifier: string | number): Promise { + transceiving.value = true + try { + const response = await entityService.delete({ provider, service, collection, identifier }) + + // Remove entity from state + const key = identifierKey(provider, service, collection, identifier) + delete _entities.value[key] + + console.debug('[Chrono Manager][Store] - Successfully deleted entity:', key) + return response + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to delete entity:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Retrieve delta changes for entities + * + * @param sources - source selector for delta check + * + * @returns Promise with delta changes (additions, modifications, deletions) + * + * Note: Delta returns only identifiers, not full entities. + * Caller should fetch full entities for additions/modifications separately. + */ + async function delta(sources: SourceSelector) { + transceiving.value = true + try { + const response = await entityService.delta({ sources }) + + // Process delta and update store + Object.entries(response).forEach(([provider, providerData]) => { + // Skip if no changes for provider + if (providerData === false) return + + Object.entries(providerData).forEach(([service, serviceData]) => { + // Skip if no changes for service + if (serviceData === false) return + + Object.entries(serviceData).forEach(([collection, collectionData]) => { + // Skip if no changes for collection + if (collectionData === false) return + + // Process deletions (remove from store) + if (collectionData.deletions && collectionData.deletions.length > 0) { + collectionData.deletions.forEach((identifier) => { + const key = identifierKey(provider, service, collection, identifier) + delete _entities.value[key] + }) + } + + // Note: additions and modifications contain only identifiers + // The caller should fetch full entities using the fetch() method + }) + }) + }) + + console.debug('[Chrono Manager][Store] - Successfully processed delta changes') + return response + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to process delta:', error) + throw error + } finally { + transceiving.value = false + } + } + + // Return public API return { - // State + // State (readonly) + transceiving: readonly(transceiving), + // Getters + count, + has, entities, - + entitiesForCollection, // Actions - reset, + entity, list, fetch, - fresh, + extant, create, - modify, - destroy, - }; -}); + update, + delete: remove, + delta, + } +}) diff --git a/src/stores/index.ts b/src/stores/index.ts index 09a4297..0bfe7cc 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1,7 +1,3 @@ -/** - * Central export point for all Chrono Manager stores - */ - export { useCollectionsStore } from './collectionsStore'; export { useEntitiesStore } from './entitiesStore'; export { useProvidersStore } from './providersStore'; diff --git a/src/stores/providersStore.ts b/src/stores/providersStore.ts index 38bbd9f..a7141b6 100644 --- a/src/stores/providersStore.ts +++ b/src/stores/providersStore.ts @@ -1,62 +1,142 @@ /** - * Chrono Manager - Providers Store + * Providers Store */ -import { defineStore } from 'pinia'; -import { ref } from 'vue'; -import { providerService } from '../services/providerService'; -import type { - SourceSelector, - ProviderInterface, -} from '../types'; +import { ref, computed, readonly } from 'vue' +import { defineStore } from 'pinia' +import { providerService } from '../services' +import { ProviderObject } from '../models/provider' +import type { SourceSelector } from '../types' export const useProvidersStore = defineStore('chronoProvidersStore', () => { // State - const providers = ref>({}); + const _providers = ref>({}) + const transceiving = ref(false) + + /** + * Get count of providers in store + */ + const count = computed(() => Object.keys(_providers.value).length) + + /** + * Check if any providers are present in store + */ + const has = computed(() => count.value > 0) + + /** + * Get all providers present in store + */ + const providers = computed(() => Object.values(_providers.value)) + + /** + * Get a specific provider from store, with optional retrieval + * + * @param identifier - Provider identifier + * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only + * + * @returns Provider object or null + */ + function provider(identifier: string, retrieve: boolean = false): ProviderObject | null { + if (retrieve === true && !_providers.value[identifier]) { + console.debug(`[Chrono Manager][Store] - Force fetching provider "${identifier}"`) + fetch(identifier) + } + + return _providers.value[identifier] || null + } // Actions /** - * List all available providers - * - * @returns Promise with provider list keyed by provider ID + * Retrieve all or specific providers, optionally filtered by source selector + * + * @param request - list request parameters + * + * @returns Promise with provider object list keyed by provider identifier */ - async function list(): Promise> { + async function list(sources?: SourceSelector): Promise> { + transceiving.value = true try { - const response = await providerService.list(); - - console.debug('[Chrono Manager](Store) - Successfully retrieved', Object.keys(response).length, 'providers:', Object.keys(response)); - - providers.value = response; - return response; + const providers = await providerService.list({ sources }) + + // Merge retrieved providers into state + _providers.value = { ..._providers.value, ...providers } + + console.debug('[Chrono Manager][Store] - Successfully retrieved', Object.keys(providers).length, 'providers') + return providers } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to retrieve providers:', error); - throw error; + console.error('[Chrono Manager][Store] - Failed to retrieve providers:', error) + throw error + } finally { + transceiving.value = false } } /** - * Check which providers exist/are available - * - * @param sources - Source selector with provider IDs to check - * @returns Promise with provider availability status + * Retrieve a specific provider by identifier + * + * @param identifier - provider identifier + * + * @returns Promise with provider object */ - async function extant(sources: SourceSelector): Promise> { + async function fetch(identifier: string): Promise { + transceiving.value = true try { - const response = await providerService.extant(sources); - return response; + const provider = await providerService.fetch({ identifier }) + + // Merge fetched provider into state + _providers.value[provider.identifier] = provider + + console.debug('[Chrono Manager][Store] - Successfully fetched provider:', provider.identifier) + return provider } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to check provider existence:', error); - throw error; + console.error('[Chrono Manager][Store] - Failed to fetch provider:', error) + throw error + } finally { + transceiving.value = false } } + /** + * Retrieve provider availability status for a given source selector + * + * @param sources - source selector to check availability for + * + * @returns Promise with provider availability status + */ + async function extant(sources: SourceSelector) { + transceiving.value = true + try { + const response = await providerService.extant({ sources }) + + Object.entries(response).forEach(([providerId, providerStatus]) => { + if (providerStatus === false) { + delete _providers.value[providerId] + } + }) + + console.debug('[Chrono Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'providers') + return response + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to check providers:', error) + throw error + } finally { + transceiving.value = false + } + } + + // Return public API return { // State + transceiving: readonly(transceiving), + // computed + count, + has, providers, - - // Actions + provider, + // functions list, + fetch, extant, - }; -}); + } +}) diff --git a/src/stores/servicesStore.ts b/src/stores/servicesStore.ts index 0f53f8b..23e2ae4 100644 --- a/src/stores/servicesStore.ts +++ b/src/stores/servicesStore.ts @@ -1,95 +1,259 @@ /** - * Chrono Manager - Services Store + * Services Store */ -import { defineStore } from 'pinia'; -import { ref } from 'vue'; -import { serviceService } from '../services/serviceService'; -import { ServiceObject } from '../models/service'; -import type { ServiceInterface } from '../types/service'; +import { ref, computed, readonly } from 'vue' +import { defineStore } from 'pinia' +import { serviceService } from '../services' +import { ServiceObject } from '../models/service' import type { SourceSelector, - ListFilter, - ListSort, -} from '../types/common'; + ServiceInterface, +} from '../types' export const useServicesStore = defineStore('chronoServicesStore', () => { // State - const services = ref([]); - // Actions + const _services = ref>({}) + const transceiving = ref(false) /** - * Retrieve services from the server + * Get count of services in store */ - async function list( - sources?: SourceSelector, - filter?: ListFilter, - sort?: ListSort, - uid?: string - ): Promise { - try { - const response = await serviceService.list({ sources, filter, sort, uid }); + const count = computed(() => Object.keys(_services.value).length) - // Flatten the nested response into a flat array - const flatServices: ServiceObject[] = []; - Object.entries(response).forEach(([providerId, providerServices]) => { - Object.values(providerServices).forEach((service: ServiceInterface) => { - // Ensure provider is set on the service object - service.provider = service.provider || providerId; - flatServices.push(new ServiceObject().fromJson(service)); - }); - }); + /** + * Check if any services are present in store + */ + const has = computed(() => count.value > 0) - console.debug('[Chrono Manager](Store) - Successfully retrieved', flatServices.length, 'services:', flatServices.map(s => ({ - id: s.id, - label: s.label, - provider: s.provider - }))); + /** + * Get all services present in store + */ + const services = computed(() => Object.values(_services.value)) - services.value = flatServices; - return flatServices; - } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to retrieve services:', error); - throw error; + /** + * Get all services present in store grouped by provider + */ + const servicesByProvider = computed(() => { + const groups: Record = {} + + Object.values(_services.value).forEach((service) => { + const providerServices = (groups[service.provider] ??= []) + providerServices.push(service) + }) + + return groups + }) + + /** + * Get a specific service from store, with optional retrieval + * + * @param provider - provider identifier + * @param identifier - service identifier + * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only + * + * @returns Service object or null + */ + function service(provider: string, identifier: string | number, retrieve: boolean = false): ServiceObject | null { + const key = identifierKey(provider, identifier) + if (retrieve === true && !_services.value[key]) { + console.debug(`[Chrono Manager][Store] - Force fetching service "${key}"`) + fetch(provider, identifier) } + + return _services.value[key] || null } /** - * Fetch a specific service - * - * @param provider - Provider identifier - * @param identifier - Service identifier - * @param uid - Optional user identifier + * Unique key for a service + */ + function identifierKey(provider: string, identifier: string | number | null): string { + return `${provider}:${identifier ?? ''}` + } + + // Actions + + /** + * Retrieve all or specific services, optionally filtered by source selector + * + * @param sources - optional source selector + * + * @returns Promise with service object list keyed by provider and service identifier + */ + async function list(sources?: SourceSelector): Promise> { + transceiving.value = true + try { + const response = await serviceService.list({ sources }) + + // Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object + const services: Record = {} + Object.entries(response).forEach(([_providerId, providerServices]) => { + Object.entries(providerServices).forEach(([_serviceId, serviceObj]) => { + const key = identifierKey(serviceObj.provider, serviceObj.identifier) + services[key] = serviceObj + }) + }) + + // Merge retrieved services into state + _services.value = { ..._services.value, ...services } + + console.debug('[Chrono Manager][Store] - Successfully retrieved', Object.keys(services).length, 'services') + return services + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to retrieve services:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Retrieve a specific service by provider and identifier + * + * @param provider - provider identifier + * @param identifier - service identifier + * * @returns Promise with service object */ - async function fetch( - provider: string, - identifier: string, - uid?: string - ): Promise { + async function fetch(provider: string, identifier: string | number): Promise { + transceiving.value = true try { - const response = await serviceService.fetch({ provider, service: identifier, uid }); - return new ServiceObject().fromJson(response); + const service = await serviceService.fetch({ provider, identifier }) + + // Merge fetched service into state + const key = identifierKey(service.provider, service.identifier) + _services.value[key] = service + + console.debug('[Chrono Manager][Store] - Successfully fetched service:', key) + return service } catch (error: any) { - console.error('[Chrono Manager](Store) - Failed to fetch service:', error); - throw error; + console.error('[Chrono Manager][Store] - Failed to fetch service:', error) + throw error + } finally { + transceiving.value = false } } /** - * Create a fresh service object with default values + * Retrieve service availability status for a given source selector + * + * @param sources - source selector to check availability for + * + * @returns Promise with service availability status */ - function fresh(): ServiceObject { - return new ServiceObject(); + async function extant(sources: SourceSelector) { + transceiving.value = true + try { + const response = await serviceService.extant({ sources }) + + console.debug('[Chrono Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'services') + return response + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to check services:', error) + throw error + } finally { + transceiving.value = false + } } - return { - // State - services, + /** + * Create a new service with given provider and data + * + * @param provider - provider identifier for the new service + * @param data - partial service data for creation + * + * @returns Promise with created service object + */ + async function create(provider: string, data: Partial): Promise { + transceiving.value = true + try { + const service = await serviceService.create({ provider, data }) + + // Merge created service into state + const key = identifierKey(service.provider, service.identifier) + _services.value[key] = service + + console.debug('[Chrono Manager][Store] - Successfully created service:', key) + return service + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to create service:', error) + throw error + } finally { + transceiving.value = false + } + } + /** + * Update an existing service with given provider, identifier, and data + * + * @param provider - provider identifier for the service to update + * @param identifier - service identifier for the service to update + * @param data - partial service data for update + * + * @returns Promise with updated service object + */ + async function update(provider: string, identifier: string | number, data: Partial): Promise { + transceiving.value = true + try { + const service = await serviceService.update({ provider, identifier, data }) + + // Merge updated service into state + const key = identifierKey(service.provider, service.identifier) + _services.value[key] = service + + console.debug('[Chrono Manager][Store] - Successfully updated service:', key) + return service + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to update service:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Delete a service by provider and identifier + * + * @param provider - provider identifier for the service to delete + * @param identifier - service identifier for the service to delete + * + * @returns Promise with deletion result + */ + async function remove(provider: string, identifier: string | number): Promise { + transceiving.value = true + try { + await serviceService.delete({ provider, identifier }) + + // Remove deleted service from state + const key = identifierKey(provider, identifier) + delete _services.value[key] + + console.debug('[Chrono Manager][Store] - Successfully deleted service:', key) + } catch (error: any) { + console.error('[Chrono Manager][Store] - Failed to delete service:', error) + throw error + } finally { + transceiving.value = false + } + } + + // Return public API + return { + // State (readonly) + transceiving: readonly(transceiving), + // Getters + count, + has, + services, + servicesByProvider, + // Actions + service, list, fetch, - fresh, - }; -}); + extant, + create, + update, + delete: remove, + } +}) diff --git a/src/types/collection.ts b/src/types/collection.ts index ad75170..a0b8751 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -1,70 +1,57 @@ /** - * Collection-related type definitions for Chrono Manager + * Collection type definitions */ - -import type { ListFilter, ListSort, SourceSelector } from "./common"; +import type { ListFilter, ListSort, SourceSelector } from './common'; /** - * Permission settings for a collection - */ -export interface CollectionPermissionInterface { - view: boolean; - create: boolean; - modify: boolean; - destroy: boolean; - share: boolean; -} - -/** - * Permissions settings for multiple users in a collection - */ -export interface CollectionPermissionsInterface { - [userId: string]: CollectionPermissionInterface; -} - -/** - * Content type settings for a collection + * Collection information */ export interface CollectionContentsInterface { event?: boolean; task?: boolean; journal?: boolean; - [contentType: string]: boolean | undefined; } -/** - * Represents a collection (calendar) within a service - */ export interface CollectionInterface { + provider: string; + service: string | number; + collection: string | number | null; + identifier: string | number; + signature?: string | null; + created?: string | null; + modified?: string | null; + properties: CollectionPropertiesInterface; +} + +export interface CollectionBaseProperties { '@type': string; - provider: string | null; - service: string | null; - in: number | string | null; - id: number | string | null; - label: string | null; - description: string | null; - priority: number | null; - visibility: string | null; - color: string | null; - enabled: boolean; - signature: string | null; - permissions: CollectionPermissionsInterface; + version: number; +} + +export interface CollectionImmutableProperties extends CollectionBaseProperties { + total?: number; contents: CollectionContentsInterface; } +export interface CollectionMutableProperties extends CollectionBaseProperties { + label: string; + description: string | null; + rank: number | null; + visibility: boolean | null; + color: string | null; +} + +export interface CollectionPropertiesInterface extends CollectionMutableProperties, CollectionImmutableProperties {} + /** - * Request to collection list endpoint + * Collection list */ export interface CollectionListRequest { sources?: SourceSelector; filter?: ListFilter; sort?: ListSort; - uid?: string; } -/** - * Response from collection list endpoint - */ export interface CollectionListResponse { [providerId: string]: { [serviceId: string]: { @@ -74,16 +61,23 @@ export interface CollectionListResponse { } /** - * Request to collection extant endpoint + * Collection fetch + */ +export interface CollectionFetchRequest { + provider: string; + service: string | number; + collection: string | number; +} + +export interface CollectionFetchResponse extends CollectionInterface {} + +/** + * Collection extant */ export interface CollectionExtantRequest { sources: SourceSelector; - uid?: string; } -/** - * Response from collection extant endpoint - */ export interface CollectionExtantResponse { [providerId: string]: { [serviceId: string]: { @@ -93,66 +87,42 @@ export interface CollectionExtantResponse { } /** - * Request to collection fetch endpoint - */ -export interface CollectionFetchRequest { - provider: string; - service: string; - identifier: string | number; - uid?: string; -} - -/** - * Response from collection fetch endpoint - */ -export interface CollectionFetchResponse extends CollectionInterface {} - -/** - * Request to collection create endpoint + * Collection create */ export interface CollectionCreateRequest { provider: string; - service: string; - data: CollectionInterface; - options?: (string)[] - uid?: string; + service: string | number; + collection?: string | number | null; // Parent Collection Identifier + properties: CollectionMutableProperties; } -/** - * Response from collection create endpoint - */ -export interface CollectionCreateResponse extends CollectionInterface {} +export interface CollectionCreateResponse extends CollectionInterface {} /** - * Request to collection modify endpoint + * Collection modify */ -export interface CollectionModifyRequest { +export interface CollectionUpdateRequest { provider: string; - service: string; + service: string | number; identifier: string | number; - data: CollectionInterface; - uid?: string; + properties: CollectionMutableProperties; } -/** - * Response from collection modify endpoint - */ -export interface CollectionModifyResponse extends CollectionInterface {} +export interface CollectionUpdateResponse extends CollectionInterface {} /** - * Request to collection destroy endpoint + * Collection delete */ -export interface CollectionDestroyRequest { +export interface CollectionDeleteRequest { provider: string; - service: string; + service: string | number; identifier: string | number; - uid?: string; + options?: { + force?: boolean; // Whether to force delete even if collection is not empty + recursive?: boolean; // Whether to delete child collections/items as well + }; } - -/** - * Response from collection destroy endpoint - */ -export interface CollectionDestroyResponse { +export interface CollectionDeleteResponse { success: boolean; } diff --git a/src/types/common.ts b/src/types/common.ts index b9926de..ab912d4 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,18 +1,62 @@ /** - * Common types shared across Chrono Manager services + * Common types shared across provider, service, collection, and entity request and responses. */ -import type { FilterComparisonOperator, FilterConjunctionOperator } from './service'; +/** + * Base API request envelope + */ +export interface ApiRequest { + version: number; + transaction: string; + operation: string; + data: T; + user?: string; +} /** - * Source selector structure for hierarchical resource selection - * Structure: Provider -> Service -> Collection -> Entity + * Success response envelope + */ +export interface ApiSuccessResponse { + version: number; + transaction: string; + operation: string; + status: 'success'; + data: T; +} + +/** + * Error response envelope + */ +export interface ApiErrorResponse { + version: number; + transaction: string; + operation: string; + status: 'error'; + data: { + code: number; + message: string; + }; +} + +/** + * Combined response type + */ +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +/** + * Selector for targeting specific providers, services, collections, or entities in list or extant operations. * - * Examples: - * - Simple boolean: { "local": true } - * - Nested services: { "system": { "personal": true, "recents": true } } - * - Collection IDs: { "system": { "personal": { "299": true, "176": true } } } - * - Entity IDs: { "system": { "personal": { "299": [1350, 1353, 5000] } } } + * Example usage: + * { + * "provider1": true, // Select all services/collections/entities under provider1 + * "provider2": { + * "serviceA": true, // Select all collections/entities under serviceA of provider2 + * "serviceB": { + * "collectionX": true, // Select all entities under collectionX of serviceB of provider2 + * "collectionY": [1, 2, 3] // Select entities with identifiers 1, 2, and 3 under collectionY of serviceB of provider2 + * } + * } + * } */ export type SourceSelector = { [provider: string]: boolean | ServiceSelector; @@ -28,38 +72,85 @@ export type CollectionSelector = { export type EntitySelector = (string | number)[]; -/** - * Filter condition for building complex queries - */ -export interface FilterCondition { - attribute: string; - value: string | number | boolean | any[]; - comparator?: FilterComparisonOperator; - conjunction?: FilterConjunctionOperator; -} /** - * Filter criteria for list operations - * Can be simple key-value pairs or complex filter conditions + * Filter comparison for list operations + */ +export const ListFilterComparisonOperator = { + EQ: 1, // Equal + NEQ: 2, // Not Equal + GT: 4, // Greater Than + LT: 8, // Less Than + GTE: 16, // Greater Than or Equal + LTE: 32, // Less Than or Equal + IN: 64, // In Array + NIN: 128, // Not In Array + LIKE: 256, // Like + NLIKE: 512, // Not Like +} as const; + +export type ListFilterComparisonOperator = typeof ListFilterComparisonOperator[keyof typeof ListFilterComparisonOperator]; + +/** + * Filter conjunction for list operations + */ +export const ListFilterConjunctionOperator = { + NONE: '', + AND: 'AND', + OR: 'OR', +} as const; + +export type ListFilterConjunctionOperator = typeof ListFilterConjunctionOperator[keyof typeof ListFilterConjunctionOperator]; + +/** + * Filter condition for list operations + * + * Tuple format: [value, comparator?, conjunction?] + */ +export type ListFilterCondition = [ + string | number | boolean | string[] | number[], + ListFilterComparisonOperator?, + ListFilterConjunctionOperator? +]; + +/** + * Filter for list operations + * + * Values can be: + * - Simple primitives (string | number | boolean) for default equality comparison + * - ListFilterCondition tuple for explicit comparator/conjunction + * + * Examples: + * - Simple usage: { name: "John" } + * - With comparator: { age: [25, ListFilterComparisonOperator.GT] } + * - With conjunction: { age: [25, ListFilterComparisonOperator.GT, ListFilterConjunctionOperator.AND] } + * - With array value for IN operator: { status: [["active", "pending"], ListFilterComparisonOperator.IN] } */ export interface ListFilter { - label?: string; - [key: string]: any; + [attribute: string]: string | number | boolean | ListFilterCondition; } /** - * Sort options for list operations + * Sort for list operations + * + * Values can be: + * - true for ascending + * - false for descending */ export interface ListSort { - [key: string]: boolean; + [attribute: string]: boolean; } /** - * Range specification for pagination/limiting results + * Range for list operations + * + * Values can be: + * - relative based on item identifier + * - absolute based on item count */ export interface ListRange { type: 'tally'; - anchor: 'absolute' | 'relative'; - position: number; + anchor: 'relative' | 'absolute'; + position: string | number; tally: number; -} +} \ No newline at end of file diff --git a/src/types/entity.ts b/src/types/entity.ts index 70fc843..c113cfa 100644 --- a/src/types/entity.ts +++ b/src/types/entity.ts @@ -1,166 +1,131 @@ +/** + * Entity type definitions + */ import type { ListFilter, ListRange, ListSort, SourceSelector } from './common'; import type { EventInterface } from './event'; import type { TaskInterface } from './task'; import type { JournalInterface } from './journal'; /** - * Entity-related type definitions for Chrono Manager + * Entity definition */ - -/** - * Represents a chrono entity (event, task, or journal) - */ -export interface EntityInterface { - '@type': string; - version: number; - in: string | number | null; - id: string | number | null; - createdOn: Date | null; - createdBy: string | null; - modifiedOn: Date | null; - modifiedBy: string | null; +export interface EntityInterface { + provider: string; + service: string; + collection: string | number; + identifier: string | number; signature: string | null; - data: EventInterface | TaskInterface | JournalInterface | null; + created: string | null; + modified: string | null; + properties: T; } /** - * Request to entity list endpoint + * Entity list */ export interface EntityListRequest { sources?: SourceSelector; filter?: ListFilter; sort?: ListSort; range?: ListRange; - uid?: string; } -/** - * Response from entity list endpoint - */ export interface EntityListResponse { [providerId: string]: { [serviceId: string]: { [collectionId: string]: { - [entityId: string]: EntityInterface; + [identifier: string]: EntityInterface; }; }; }; } /** - * Request to entity delta endpoint + * Entity fetch */ -export interface EntityDeltaRequest { - sources: SourceSelector; - uid?: string; +export interface EntityFetchRequest { + provider: string; + service: string | number; + collection: string | number; + identifiers: (string | number)[]; +} + +export interface EntityFetchResponse { + [identifier: string]: EntityInterface; } /** - * Response from entity delta endpoint - */ -export interface EntityDeltaResponse { - [providerId: string]: { - [serviceId: string]: { - [collectionId: string]: { - signature: string; - created?: { - [entityId: string]: EntityInterface; - }; - modified?: { - [entityId: string]: EntityInterface; - }; - deleted?: string[]; // Array of deleted entity IDs - }; - }; - }; -} - -/** - * Request to entity extant endpoint + * Entity extant */ export interface EntityExtantRequest { sources: SourceSelector; - uid?: string; } -/** - * Response from entity extant endpoint - */ export interface EntityExtantResponse { [providerId: string]: { [serviceId: string]: { [collectionId: string]: { - [entityId: string]: boolean; + [identifier: string]: boolean; }; }; }; } /** - * Request to entity fetch endpoint + * Entity create */ -export interface EntityFetchRequest { +export interface EntityCreateRequest { provider: string; - service: string; + service: string | number; collection: string | number; - identifiers: (string | number)[]; - uid?: string; + properties: T; } -/** - * Response from entity fetch endpoint - */ -export interface EntityFetchResponse extends Record {} +export interface EntityCreateResponse extends EntityInterface {} /** - * Request to entity create endpoint + * Entity update */ -export interface EntityCreateRequest { +export interface EntityUpdateRequest { provider: string; - service: string; - collection: string | number; - data: EntityInterface; - options?: (string)[] - uid?: string; -} - -/** - * Response from entity create endpoint - */ -export interface EntityCreateResponse extends EntityInterface {} - -/** - * Request to entity modify endpoint - */ -export interface EntityModifyRequest { - provider: string; - service: string; + service: string | number; collection: string | number; identifier: string | number; - data: EntityInterface; - options?: (string)[] - uid?: string; + properties: T; } -/** - * Response from entity modify endpoint - */ -export interface EntityModifyResponse extends EntityInterface {} +export interface EntityUpdateResponse extends EntityInterface {} /** - * Request to entity destroy endpoint + * Entity delete */ -export interface EntityDestroyRequest { +export interface EntityDeleteRequest { provider: string; - service: string; + service: string | number; collection: string | number; identifier: string | number; - uid?: string; } -/** - * Response from entity destroy endpoint - */ -export interface EntityDestroyResponse { +export interface EntityDeleteResponse { success: boolean; } + +/** + * Entity delta + */ +export interface EntityDeltaRequest { + sources: SourceSelector; +} + +export interface EntityDeltaResponse { + [providerId: string]: false | { + [serviceId: string]: false | { + [collectionId: string]: false | { + signature: string; + additions: (string | number)[]; + modifications: (string | number)[]; + deletions: (string | number)[]; + }; + }; + }; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 87db923..97a2bbd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,12 +1,8 @@ -/** - * Central export point for all Chrono Manager types - */ - export type * from './collection'; export type * from './common'; export type * from './entity'; export type * from './event'; -export type * from './task'; export type * from './journal'; export type * from './provider'; export type * from './service'; +export type * from './task'; \ No newline at end of file diff --git a/src/types/provider.ts b/src/types/provider.ts index 2db5be0..b9632c8 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1,5 +1,5 @@ /** - * Provider-specific types + * Provider type definitions */ import type { SourceSelector } from "./common"; @@ -11,9 +11,11 @@ export interface ProviderCapabilitiesInterface { ServiceFetch?: boolean; ServiceExtant?: boolean; ServiceCreate?: boolean; - ServiceModify?: boolean; + ServiceUpdate?: boolean; ServiceDelete?: boolean; - [key: string]: boolean | undefined; + ServiceDiscover?: boolean; + ServiceTest?: boolean; + [key: string]: boolean | object | string[] | undefined; } /** @@ -21,33 +23,38 @@ export interface ProviderCapabilitiesInterface { */ export interface ProviderInterface { '@type': string; - id: string; + identifier: string; label: string; capabilities: ProviderCapabilitiesInterface; } /** - * Request to provider list endpoint + * Provider list */ -export interface ProviderListRequest {} +export interface ProviderListRequest { + sources?: SourceSelector; +} -/** - * Response from provider list endpoint - */ export interface ProviderListResponse { - [providerId: string]: ProviderInterface; + [identifier: string]: ProviderInterface; } /** - * Request to provider extant endpoint + * Provider fetch + */ +export interface ProviderFetchRequest { + identifier: string; +} + +export interface ProviderFetchResponse extends ProviderInterface {} + +/** + * Provider extant */ export interface ProviderExtantRequest { sources: SourceSelector; } -/** - * Response from provider extant endpoint - */ export interface ProviderExtantResponse { - [providerId: string]: boolean; + [identifier: string]: boolean; } diff --git a/src/types/service.ts b/src/types/service.ts index 47803af..d6c77dc 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -1,40 +1,254 @@ /** - * Service-related type definitions for Chrono Manager + * Service type definitions */ - -import type { ListFilter, ListSort, SourceSelector } from "./common"; +import type { SourceSelector, ListFilterComparisonOperator } from './common'; /** - * Filter comparison operators (bitmask values) + * Service capabilities */ -export const FilterComparisonOperator = { - EQ: 1, // Equal - NEQ: 2, // Not Equal - GT: 4, // Greater Than - LT: 8, // Less Than - GTE: 16, // Greater Than or Equal - LTE: 32, // Less Than or Equal - IN: 64, // In Array - NIN: 128, // Not In Array - LIKE: 256, // Like (pattern matching) - NLIKE: 512, // Not Like -} as const; - -export type FilterComparisonOperator = typeof FilterComparisonOperator[keyof typeof FilterComparisonOperator]; +export interface ServiceCapabilitiesInterface { + // Collection capabilities + CollectionList?: boolean; + CollectionListFilter?: ServiceListFilterCollection; + CollectionListSort?: ServiceListSortCollection; + CollectionExtant?: boolean; + CollectionFetch?: boolean; + CollectionCreate?: boolean; + CollectionUpdate?: boolean; + CollectionDelete?: boolean; + // Message capabilities + EntityList?: boolean; + EntityListFilter?: ServiceListFilterEntity; + EntityListSort?: ServiceListSortEntity; + EntityListRange?: ServiceListRange; + EntityDelta?: boolean; + EntityExtant?: boolean; + EntityFetch?: boolean; + EntityCreate?: boolean; + EntityUpdate?: boolean; + EntityDelete?: boolean; + EntityMove?: boolean; + EntityCopy?: boolean; + // Send capability + EntityTransmit?: boolean; + [key: string]: boolean | object | string | string[] | undefined; +} /** - * Filter conjunction operators + * Service information */ -export const FilterConjunctionOperator = { - NONE: '', - AND: 'AND', - OR: 'OR', -} as const; - -export type FilterConjunctionOperator = typeof FilterConjunctionOperator[keyof typeof FilterConjunctionOperator]; +export interface ServiceInterface { + '@type': string; + provider: string; + identifier: string | number | null; + label: string | null; + enabled: boolean; + capabilities?: ServiceCapabilitiesInterface; + location?: ServiceLocation | null; + identity?: ServiceIdentity | null; + auxiliary?: Record; // Provider-specific extension data +} /** - * Filter specification format + * Service list + */ +export interface ServiceListRequest { + sources?: SourceSelector; +} + +export interface ServiceListResponse { + [provider: string]: { + [identifier: string]: ServiceInterface; + }; +} + +/** + * Service fetch + */ +export interface ServiceFetchRequest { + provider: string; + identifier: string | number; +} + +export interface ServiceFetchResponse extends ServiceInterface {} + +/** + * Service extant + */ +export interface ServiceExtantRequest { + sources: SourceSelector; +} + +export interface ServiceExtantResponse { + [provider: string]: { + [identifier: string]: boolean; + }; +} + +/** + * Service create + */ +export interface ServiceCreateRequest { + provider: string; + data: Partial; +} + +export interface ServiceCreateResponse extends ServiceInterface {} + +/** + * Service update + */ +export interface ServiceUpdateRequest { + provider: string; + identifier: string | number; + data: Partial; +} + +export interface ServiceUpdateResponse extends ServiceInterface {} + +/** + * Service delete + */ +export interface ServiceDeleteRequest { + provider: string; + identifier: string | number; +} + +export interface ServiceDeleteResponse {} + +/** + * Service discovery + */ +export interface ServiceDiscoverRequest { + identity: string; // Email address or domain + provider?: string; // Optional: specific provider ('jmap', 'smtp', etc.) or null for all + location?: string; // Optional: known hostname (bypasses DNS lookup) + secret?: string; // Optional: password/token for credential validation +} + +export interface ServiceDiscoverResponse { + [provider: string]: ServiceLocation; // Uses existing ServiceLocation discriminated union +} + +/** + * Service connection test + */ +export interface ServiceTestRequest { + provider: string; + // For existing service + identifier?: string | number | null; + // For fresh configuration + location?: ServiceLocation | null; + identity?: ServiceIdentity | null; +} + +export interface ServiceTestResponse { + success: boolean; + message: string; +} + +/** + * Service location - Base + */ +export interface ServiceLocationBase { + type: 'URI' | 'FILE'; +} + +/** + * Service location - URI-based type + */ +export interface ServiceLocationUri extends ServiceLocationBase { + type: 'URI'; + scheme: string; // e.g., 'https', 'http' + host: string; // e.g., 'api.example.com' + port: number; // e.g., 443 + path?: string; // e.g., '/v1/api' + verifyPeer?: boolean; // Verify SSL/TLS peer certificate + verifyHost?: boolean; // Verify SSL/TLS certificate host +} + +/** + * Service location - File-based type (e.g., for local mail delivery or Unix socket) + */ +export interface ServiceLocationFile extends ServiceLocationBase { + type: 'FILE'; + path: string; // File system path +} + +/** + * Service location types + */ +export type ServiceLocation = + | ServiceLocationUri + | ServiceLocationFile; + +/** + * Service identity - base + */ +export interface ServiceIdentityBase { + type: 'NA' | 'BA' | 'TA' | 'OA' | 'CC'; +} + +/** + * Service identity - No authentication + */ +export interface ServiceIdentityNone extends ServiceIdentityBase { + type: 'NA'; +} + +/** + * Service identity - Basic authentication type + */ +export interface ServiceIdentityBasic extends ServiceIdentityBase { + type: 'BA'; + identity: string; // Username/email + secret: string; // Password +} + +/** + * Token authentication (API key, static token) + */ +export interface ServiceIdentityToken extends ServiceIdentityBase { + type: 'TA'; + token: string; // Authentication token/API key +} + +/** + * OAuth authentication + */ +export interface ServiceIdentityOAuth extends ServiceIdentityBase { + type: 'OA'; + accessToken: string; // Current access token + accessScope?: string[]; // Token scopes + accessExpiry?: number; // Unix timestamp when token expires + refreshToken?: string; // Refresh token for getting new access tokens + refreshLocation?: string; // Token refresh endpoint URL +} + +/** + * Client certificate authentication (mTLS) + */ +export interface ServiceIdentityCertificate extends ServiceIdentityBase { + type: 'CC'; + certificate: string; // X.509 certificate (PEM format or file path) + privateKey: string; // Private key (PEM format or file path) + passphrase?: string; // Optional passphrase for encrypted private key +} + +/** + * Service identity configuration + * Discriminated union of all identity types + */ +export type ServiceIdentity = + | ServiceIdentityNone + | ServiceIdentityBasic + | ServiceIdentityToken + | ServiceIdentityOAuth + | ServiceIdentityCertificate; + +/** + * List filter specification format + * * Format: "type:length:defaultComparator:supportedComparators" * * Examples: @@ -50,165 +264,33 @@ export type FilterConjunctionOperator = typeof FilterConjunctionOperator[keyof t * * Comparator values are bitmasks that can be combined */ -export type FilterSpec = string; +export type ServiceListFilterCollection = { + 'label'?: string; + 'rank'?: string; + [attribute: string]: string | undefined; +}; + +export type ServiceListFilterEntity = { + '*'?: string; + 'before'?: string; + 'after'?: string; + [attribute: string]: string | undefined; +} /** - * Parsed filter specification + * Service list sort specification */ -export interface ParsedFilterSpec { - type: 'string' | 'integer' | 'boolean' | 'array'; +export type ServiceListSortCollection = ("label" | "rank" | string)[]; +export type ServiceListSortEntity = ( "sent" | "size" | string)[]; + +export type ServiceListRange = { + 'tally'?: string[]; +}; + + +export interface ServiceListFilterDefinition { + type: 'string' | 'integer' | 'date' | 'boolean' | 'array'; length: number; - defaultComparator: FilterComparisonOperator; - supportedComparators: FilterComparisonOperator[]; + defaultComparator: ListFilterComparisonOperator; + supportedComparators: ListFilterComparisonOperator[]; } - -/** - * Parse a filter specification string into its components - * - * @param spec - Filter specification string (e.g., "s:200:256:771") - * @returns Parsed filter specification object - * - * @example - * parseFilterSpec("s:200:256:771") - * // Returns: { - * // type: 'string', - * // length: 200, - * // defaultComparator: 256 (LIKE), - * // supportedComparators: [1, 2, 256, 512] (EQ, NEQ, LIKE, NLIKE) - * // } - */ -export function parseFilterSpec(spec: FilterSpec): ParsedFilterSpec { - const [typeCode, lengthStr, defaultComparatorStr, supportedComparatorsStr] = spec.split(':'); - - const typeMap: Record = { - 's': 'string', - 'i': 'integer', - 'b': 'boolean', - 'a': 'array', - }; - - const type = typeMap[typeCode]; - if (!type) { - throw new Error(`Invalid filter type code: ${typeCode}`); - } - - const length = parseInt(lengthStr, 10); - const defaultComparator = parseInt(defaultComparatorStr, 10) as FilterComparisonOperator; - - // Parse supported comparators from bitmask - const supportedComparators: FilterComparisonOperator[] = []; - const supportedBitmask = parseInt(supportedComparatorsStr, 10); - - if (supportedBitmask !== 0) { - const allComparators = Object.values(FilterComparisonOperator).filter(v => typeof v === 'number') as number[]; - for (const comparator of allComparators) { - if ((supportedBitmask & comparator) === comparator) { - supportedComparators.push(comparator as FilterComparisonOperator); - } - } - } - - return { - type, - length, - defaultComparator, - supportedComparators, - }; -} - -/** - * Capabilities available for a service - */ -export interface ServiceCapabilitiesInterface { - // Collection capabilities - CollectionList?: boolean; - CollectionListFilter?: { - [key: string]: FilterSpec; - }; - CollectionListSort?: string[]; - CollectionExtant?: boolean; - CollectionFetch?: boolean; - CollectionCreate?: boolean; - CollectionModify?: boolean; - CollectionDestroy?: boolean; - - // Entity capabilities - EntityList?: boolean; - EntityListFilter?: { - [key: string]: FilterSpec; - }; - EntityListSort?: string[]; - EntityListRange?: { - [rangeType: string]: string[]; // e.g., { "tally": ["absolute", "relative"] } - }; - EntityDelta?: boolean; - EntityExtant?: boolean; - EntityFetch?: boolean; - EntityCreate?: boolean; - EntityModify?: boolean; - EntityDestroy?: boolean; - EntityCopy?: boolean; - EntityMove?: boolean; -} - -/** - * Represents a service within a provider - */ -export interface ServiceInterface { - '@type': string; - provider: string; - id: string; - label: string; - capabilities?: ServiceCapabilitiesInterface; - enabled: boolean; -} - -/** - * Request to service list endpoint - */ -export interface ServiceListRequest { - sources?: SourceSelector; - filter?: ListFilter; - sort?: ListSort; - uid?: string; -} - -/** - * Response from service list endpoint - */ -export interface ServiceListResponse { - [providerId: string]: { - [serviceId: string]: ServiceInterface; - }; -} - -/** - * Request to service extant endpoint - */ -export interface ServiceExtantRequest { - sources: SourceSelector; - uid?: string; -} - -/** - * Response from service extant endpoint - */ -export interface ServiceExtantResponse { - [providerId: string]: { - [serviceId: string]: boolean; - }; -} - -/** - * Request to service fetch endpoint - */ -export interface ServiceFetchRequest { - provider: string; - service: string; - uid?: string; -} - -/** - * Response from service fetch endpoint - */ -export interface ServiceFetchResponse extends ServiceInterface {}