From dce16eff5960f5dcaadf35ffa0c9c53cb4d09bc2 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Dec 2025 09:52:59 -0500 Subject: [PATCH] Initial commit --- .gitignore | 29 + composer.json | 26 + lib/Module.php | 71 ++ lib/Providers/Personal/Collection.php | 260 ++++++++ lib/Providers/Personal/Entity.php | 162 +++++ lib/Providers/Personal/PersonalService.php | 383 +++++++++++ lib/Providers/Provider.php | 182 ++++++ lib/Providers/Shared/Collection.php | 342 ++++++++++ lib/Providers/Shared/SharedService.php | 583 +++++++++++++++++ lib/Store/Personal/Store.php | 718 +++++++++++++++++++++ 10 files changed, 2756 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 lib/Module.php create mode 100644 lib/Providers/Personal/Collection.php create mode 100644 lib/Providers/Personal/Entity.php create mode 100644 lib/Providers/Personal/PersonalService.php create mode 100644 lib/Providers/Provider.php create mode 100644 lib/Providers/Shared/Collection.php create mode 100644 lib/Providers/Shared/SharedService.php create mode 100644 lib/Store/Personal/Store.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..812e4cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/static/ + +# Backend development +/lib/vendor/ +coverage/ +phpunit.xml.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +*.log diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5d4105d --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "ktrix/people-provider-local", + "type": "project", + "authors": [ + { + "name": "Sebastian Krupinski", + "email": "krupinski01@gmail.com" + } + ], + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.2" + }, + "autoloader-suffix": "PeopleProviderLocal", + "vendor-dir": "lib/vendor" + }, + "require": { + "php": ">=8.2 <=8.5" + }, + "autoload": { + "psr-4": { + "KTXM\\PeopleProviderLocal\\": "lib/" + } + } +} diff --git a/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..2f5fe59 --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,71 @@ + [ + 'label' => 'Access People Provider Local', + 'description' => 'View and access the local people provider module', + 'group' => 'People Providers' + ], + ]; + } + + public function boot(): void + { + $this->providerManager->register(ProviderInterface::TYPE_PEOPLE, 'default', Provider::class); + } + + public function registerBI(): array { + return [ + 'handle' => $this->handle(), + 'namespace' => 'PeopleProviderLocal', + 'version' => $this->version(), + 'label' => $this->label(), + 'author' => $this->author(), + 'description' => $this->description() + ]; + } +} diff --git a/lib/Providers/Personal/Collection.php b/lib/Providers/Personal/Collection.php new file mode 100644 index 0000000..4e558b6 --- /dev/null +++ b/lib/Providers/Personal/Collection.php @@ -0,0 +1,260 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\PeopleProviderLocal\Providers\Personal; + +use KTXF\People\Collection\CollectionContent; +use KTXF\People\Collection\CollectionPermissions; +use KTXF\People\Collection\CollectionRoles; +use KTXF\People\Collection\ICollectionMutable; + +class Collection implements ICollectionMutable { + + private ?string $userId = null; + private string $providerId = 'default'; + private string $serviceId = 'personal'; + private ?string $collectionId = null; + private ?string $collectionUuid = null; + private ?string $collectionLabel = null; + private ?string $collectionDescription = null; + private ?int $collectionPriority = null; + private ?bool $collectionVisibility = null; + private ?string $collectionColor = null; + private ?string $collectionCreatedOn = null; + private ?string $collectionModifiedOn = null; + private bool $collectionEnabled = true; + private ?string $collectionSignature = null; + private array $collectionPermissions = [ + CollectionPermissions::View->value => true, + CollectionPermissions::Create->value => true, + CollectionPermissions::Modify->value => true, + CollectionPermissions::Destroy->value => true, + CollectionPermissions::Share->value => true, + ]; + private array $collectionAttributes = [ + 'roles' => [ + CollectionRoles::Individual->value => true, + ], + 'contents' => [ + CollectionContent::Individual->value => true, + CollectionContent::Organization->value => true, + CollectionContent::Group->value => true, + ], + ]; + + public function jsonSerialize(): mixed { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_PROVIDER => $this->providerId, + self::JSON_PROPERTY_SERVICE => $this->serviceId, + self::JSON_PROPERTY_IN => null, + self::JSON_PROPERTY_ID => $this->collectionId, + self::JSON_PROPERTY_LABEL => $this->collectionLabel, + self::JSON_PROPERTY_DESCRIPTION => $this->collectionDescription, + self::JSON_PROPERTY_PRIORITY => $this->collectionPriority, + self::JSON_PROPERTY_VISIBILITY => $this->collectionVisibility, + self::JSON_PROPERTY_COLOR => $this->collectionColor, + self::JSON_PROPERTY_CREATED => $this->collectionCreatedOn, + self::JSON_PROPERTY_MODIFIED => $this->collectionModifiedOn, + self::JSON_PROPERTY_ENABLED => $this->collectionEnabled, + self::JSON_PROPERTY_SIGNATURE => $this->collectionSignature, + self::JSON_PROPERTY_PERMISSIONS => [$this->userId => $this->collectionPermissions], + self::JSON_PROPERTY_ROLES => $this->collectionAttributes['roles'] ?? [], + self::JSON_PROPERTY_CONTENTS => $this->collectionAttributes['contents'] ?? [], + ]; + } + + public function jsonDeserialize(array|string $data): static + { + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->collectionId = $data[self::JSON_PROPERTY_ID] ?? null; + $this->collectionLabel = $data[self::JSON_PROPERTY_LABEL] ?? null; + $this->collectionDescription = $data[self::JSON_PROPERTY_DESCRIPTION] ?? null; + $this->collectionPriority = $data[self::JSON_PROPERTY_PRIORITY] ?? null; + $this->collectionVisibility = $data[self::JSON_PROPERTY_VISIBILITY] ?? null; + $this->collectionColor = $data[self::JSON_PROPERTY_COLOR] ?? null; + $this->collectionCreatedOn = $data[self::JSON_PROPERTY_CREATED] ?? null; + $this->collectionModifiedOn = $data[self::JSON_PROPERTY_MODIFIED] ?? null; + $this->collectionEnabled = $data[self::JSON_PROPERTY_ENABLED] ?? true; + $this->collectionSignature = $data[self::JSON_PROPERTY_SIGNATURE] ?? null; + + return $this; + } + + public function fromStore(array|object $data): self + { + // Convert object to array if needed + if (is_object($data)) { + $data = (array) $data; + } + + // extract properties + if (isset($data['cid'])) { + $this->collectionId = $data['cid']; + } elseif (isset($data['_id'])) { + if (is_object($data['_id']) && method_exists($data['_id'], '__toString')) { + $this->collectionId = (string) $data['_id']; + } elseif (is_array($data['_id']) && isset($data['_id']['$oid'])) { + $this->collectionId = $data['_id']['$oid']; + } else { + $this->collectionId = (string) $data['_id']; + } + } + $this->userId = $data['uid'] ?? null; + $this->collectionLabel = $data['label'] ?? null; + $this->collectionDescription = $data['description'] ?? null; + $this->collectionColor = $data['color'] ?? null; + $this->collectionCreatedOn = $data['created'] ?? null; + $this->collectionModifiedOn = $data['modified'] ?? null; + $this->collectionEnabled = $data['enabled'] ?? true; + $this->collectionSignature = isset($data['signature']) ? md5((string)$data['signature']) : null; + // Handle BSON array if present + if (isset($data['tags'])) { + if (is_object($data['tags']) && method_exists($data['tags'], 'bsonSerialize')) { + $tags = $data['tags']->bsonSerialize(); + } else { + $tags = $data['tags'] ?? []; + } + } + + return $this; + } + + public function toStore(): array + { + $data = [ + 'uid' => $this->userId, + 'uuid' => $this->collectionUuid, + 'label' => $this->collectionLabel, + 'description' => $this->collectionDescription, + 'color' => $this->collectionColor, + 'created' => $this->collectionCreatedOn, + 'modified' => $this->collectionModifiedOn, + 'signature' => $this->collectionSignature, + 'enabled' => $this->collectionEnabled, + ]; + + // Only include _id if it exists (for updates) + if ($this->collectionId !== null) { + $data['_id'] = $this->collectionId; + } + + return $data; + } + + public function in(): null { + return null; + } + + public function id(): string { + return $this->collectionId; + } + + public function created(): ?\DateTimeImmutable { + return $this->collectionCreatedOn ? new \DateTimeImmutable($this->collectionCreatedOn) : null; + } + + public function modified(): ?\DateTimeImmutable { + return $this->collectionModifiedOn ? new \DateTimeImmutable($this->collectionModifiedOn) : null; + } + + public function attributes(): array { + return $this->collectionAttributes; + } + + public function uuid(): string { + return $this->collectionUuid; + } + + public function signature(): ?string { + return $this->collectionSignature; + } + + public function roles(): array { + return $this->collectionAttributes['roles'] ?? []; + } + + public function role(CollectionRoles $role): bool { + return $this->collectionAttributes['roles'][$role->value] ?? false; + } + + public function contents(): array { + return $this->collectionAttributes['content'] ?? []; + } + + public function contains(CollectionContent $content): bool { + return $this->collectionAttributes['content'][$content->value] ?? false; + } + + public function getEnabled(): bool { + return (bool)$this->collectionEnabled; + } + + public function setEnabled(bool $value): self { + $this->collectionEnabled = $value; + return $this; + } + + public function getPermissions(): array { + return [$this->userId => $this->collectionPermissions]; + } + + public function hasPermission(CollectionPermissions $permission): bool { + return $this->collectionPermissions[$permission->value] ?? false; + } + + public function getLabel(): ?string { + return $this->collectionLabel; + } + + public function setLabel(string $value): self { + $this->collectionLabel = $value; + return $this; + } + + public function getDescription(): ?string { + return $this->collectionDescription; + } + + public function setDescription(?string $value): self { + $this->collectionDescription = $value; + return $this; + } + + public function getPriority(): ?int { + return $this->collectionPriority; + } + + public function setPriority(?int $value): self { + $this->collectionPriority = $value; + return $this; + } + + public function getVisibility(): ?bool { + return $this->collectionVisibility; + } + + public function setVisibility(?bool $value): self { + $this->collectionVisibility = $value; + return $this; + } + + public function getColor(): ?string { + return $this->collectionColor; + } + + public function setColor(?string $value): self { + $this->collectionColor = $value; + return $this; + } + +} diff --git a/lib/Providers/Personal/Entity.php b/lib/Providers/Personal/Entity.php new file mode 100644 index 0000000..a2e9be1 --- /dev/null +++ b/lib/Providers/Personal/Entity.php @@ -0,0 +1,162 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\PeopleProviderLocal\Providers\Personal; + +use DateTimeImmutable; +use KTXF\People\Entity\IEntityBase; +use KTXF\People\Entity\IEntityMutable; +use KTXF\People\Entity\Individual\IndividualObject; + +/** + * Entity wrapper - contains metadata and EntityData + */ +class Entity implements IEntityBase, IEntityMutable { + + // Metadata fields (system-managed) + private ?string $entityId = null; + private ?string $tenantId = null; + private ?string $userId = null; + private ?string $collectionId = null; + private ?string $createdOn = null; + private ?string $modifiedOn = null; + private ?string $entitySignature = null; + + // Entity display properties + private ?int $entityPriority = null; + private ?bool $entityVisibility = null; + private ?string $entityColor = null; + private string|array|null $entityData = null; + + public function jsonSerialize(): mixed { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_IN => $this->collectionId, + self::JSON_PROPERTY_ID => $this->entityId, + self::JSON_PROPERTY_DATA => $this->entityData, + self::JSON_PROPERTY_SIGNATURE => $this->entitySignature, + ]; + } + + public function jsonDeserialize(array|string $data): static + { + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->entityId = $data[self::JSON_PROPERTY_ID] ?? null; + $this->collectionId = $data[self::JSON_PROPERTY_IN] ?? null; + $this->entitySignature = $data[self::JSON_PROPERTY_SIGNATURE] ?? null; + $this->entityData = $data[self::JSON_PROPERTY_DATA] ?? null; + + return $this; + } + + public function fromStore(array|object $document): self { + // Convert object to array if needed + if (is_object($document)) { + $document = (array) $document; + } + + // Load metadata + $this->entityId = $document['eid'] ?? null; + $this->tenantId = $document['tid'] ?? null; + $this->userId = $document['uid'] ?? null; + $this->collectionId = $document['cid'] ?? null; + $this->createdOn = $document['createdOn'] ?? null; + $this->modifiedOn = $document['modifiedOn'] ?? null; + $this->entityData = $document['data'] ?? null; + $this->entitySignature = md5(json_encode($this->entityData)); + + return $this; + } + + public function toStore(): array { + $document = [ + 'tid' => $this->tenantId, + 'uid' => $this->userId, + 'cid' => $this->collectionId, + 'eid' => $this->entityId, + 'createdOn' => $this->createdOn ?? date('c'), + 'modifiedOn' => date('c'), + 'data' => $this->entityData, + ]; + + return $document; + } + + public function in(): string|int { + return $this->collectionId ?? ''; + } + + public function id(): string|int { + return $this->entityId ?? ''; + } + + public function created(): ?DateTimeImmutable { + return $this->createdOn ? new DateTimeImmutable($this->createdOn) : null; + } + + public function modified(): ?DateTimeImmutable { + return $this->modifiedOn ? new DateTimeImmutable($this->modifiedOn) : null; + } + + public function signature(): ?string { + return $this->entitySignature; + } + + public function getPriority(): ?int { + return $this->entityPriority; + } + + public function setPriority(?int $value): static { + $this->entityPriority = $value; + return $this; + } + + public function getVisibility(): ?bool { + return $this->entityVisibility; + } + + public function setVisibility(?bool $value): static { + $this->entityVisibility = $value; + return $this; + } + + public function getColor(): ?string { + return $this->entityColor; + } + + public function setColor(?string $value): static { + $this->entityColor = $value; + return $this; + } + + public function getDataObject(): IndividualObject|null { + return $this->entityData ? (new IndividualObject)->jsonDeserialize($this->entityData) : null; + } + + public function setDataObject(IndividualObject $value): static + { + $this->entityData = $value->jsonSerialize(); + return $this; + } + + public function getDataJson(): array|string|null { + return $this->entityData; + } + + public function setDataJson(array|string $value): static + { + $this->entityData = $value; + return $this; + } + +} + diff --git a/lib/Providers/Personal/PersonalService.php b/lib/Providers/Personal/PersonalService.php new file mode 100644 index 0000000..1e95c99 --- /dev/null +++ b/lib/Providers/Personal/PersonalService.php @@ -0,0 +1,383 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\PeopleProviderLocal\Providers\Personal; + +use KTXF\People\Collection\ICollectionMutable; +use KTXF\People\Entity\IEntityBase; +use KTXF\People\Service\IServiceBase; +use KTXF\People\Entity\IEntityMutable; +use KTXF\People\Service\IServiceCollectionMutable; +use KTXF\People\Service\IServiceEntityMutable; +use KTXF\Resource\Exceptions\InvalidParameterException; +use KTXF\Resource\Filter\Filter; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\RangeTally; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; +use KTXF\Resource\Sort\Sort; +use KTXM\PeopleProviderLocal\Store\Personal\Store; + +class PersonalService implements IServiceBase, IServiceCollectionMutable, IServiceEntityMutable { + + protected const SERVICE_ID = 'personal'; + protected const SERVICE_LABEL = 'Personal Contacts Service'; + protected const SERVICE_PROVIDER = 'default'; + + protected array $serviceCollectionCache = []; + protected ?string $serviceTenantId = null; + protected ?string $serviceUserId = null; + protected ?bool $serviceEnabled = true; + + protected array $serviceAbilities = [ + self::CAPABILITY_COLLECTION_LIST => true, + self::CAPABILITY_COLLECTION_LIST_FILTER => [ + 'id' => 'a:10:64:192', + 'label' => 's:100:256:771', + 'description' => 's:100:256:771', + ], + self::CAPABILITY_COLLECTION_LIST_SORT => [ + 'label', + 'description' + ], + self::CAPABILITY_COLLECTION_EXTANT => true, + self::CAPABILITY_COLLECTION_FETCH => true, + self::CAPABILITY_COLLECTION_CREATE => true, + self::CAPABILITY_COLLECTION_MODIFY => true, + self::CAPABILITY_COLLECTION_DESTROY => true, + self::CAPABILITY_ENTITY_LIST => true, + self::CAPABILITY_ENTITY_LIST_FILTER => [ + '*' => 's:200:256:771', + 'uri' => 's:200:256:771', + 'label' => 's:200:256:771', + 'phone' => 's:200:256:771', + 'email' => 's:200:256:771', + 'location' => 's:200:256:771' + ], + self::CAPABILITY_ENTITY_LIST_SORT => [ + 'label', + 'phone', + 'email', + 'location' + ], + self::CAPABILITY_ENTITY_LIST_RANGE => [ + 'tally' => ['absolute', 'relative'] + ], + self::CAPABILITY_ENTITY_DELTA => true, + self::CAPABILITY_ENTITY_EXTANT => true, + self::CAPABILITY_ENTITY_FETCH => true, + self::CAPABILITY_ENTITY_CREATE => true, + self::CAPABILITY_ENTITY_MODIFY => true, + self::CAPABILITY_ENTITY_DESTROY => true, + self::CAPABILITY_ENTITY_COPY => true, + self::CAPABILITY_ENTITY_MOVE => true, + ]; + + public function __construct( + private Store $store, + ) {} + + public function jsonSerialize(): mixed { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_PROVIDER => self::SERVICE_PROVIDER, + self::JSON_PROPERTY_ID => self::SERVICE_ID, + self::JSON_PROPERTY_LABEL => self::SERVICE_LABEL, + self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, + self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, + ]; + } + + public function init(string $tenantId, string $userId): self { + $this->serviceTenantId = $tenantId; + $this->serviceUserId = $userId; + return $this; + } + + public function capable(string $value): bool { + if (isset($this->serviceAbilities[$value])) { + return (bool)$this->serviceAbilities[$value]; + } + return false; + } + + public function capabilities(): array { + return $this->serviceAbilities; + } + + public function in(): string{ + return self::SERVICE_PROVIDER; + } + + public function id(): string { + return self::SERVICE_ID; + } + + public function getLabel(): string { + return (string)self::SERVICE_LABEL; + } + + public function getEnabled(): bool { + return (bool)$this->serviceEnabled; + } + + public function collectionList(?IFilter $filter = null, ?ISort $sort = null): array { + $entries = $this->store->collectionList($this->serviceTenantId, $this->serviceUserId, $filter, $sort); + $this->serviceCollectionCache = $entries; + return $entries ?? []; + } + + public function collectionListFilter(): Filter { + return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); + } + + public function collectionListSort(): Sort { + return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); + } + + public function collectionExtant(string|int $id): bool { + // determine if collection is cached + if (isset($this->serviceCollectionCache[$id])) { + return true; + } + // retrieve from store + return $this->store->collectionExtant($this->serviceTenantId, $this->serviceUserId, $id); + } + + public function collectionFetch(string|int $id): ?Collection { + // determine if collection is cached + if (isset($this->serviceCollectionCache[$id])) { + return $this->serviceCollectionCache[$id]; + } + // retrieve from store + $collection = $this->store->collectionFetch($this->serviceTenantId, $this->serviceUserId, $id); + if ($collection !== null) { + $this->serviceCollectionCache[$collection->id()] = $collection; + return $collection; + } + return null; + } + + public function collectionFresh(): Collection { + return new Collection(); + } + + public function collectionCreate(string|int $location, ICollectionMutable $collection, array $options): Collection { + // convert collection to a native type if needed + if (!($collection instanceof Collection)) { + $nativeCollection = new Collection(); + $nativeCollection->jsonDeserialize($collection->jsonSerialize()); + } else { + $nativeCollection = clone $collection; + } + // create collection in store + $result = $this->store->collectionCreate($this->serviceTenantId, $this->serviceUserId, $nativeCollection); + $this->serviceCollectionCache[$result->id()] = $result; + + return $result; + } + + public function collectionModify(string|int $id, ICollectionMutable $collection): Collection { + // validate id + if (!is_string($id)) { + throw new InvalidParameterException("Invalid: Collection identifier '$id' is not valid"); + } + // validate ownership + if ($this->collectionExtant($id) === false) { + throw new InvalidParameterException("Invalid: Collection identifier '$id' does not exist or does not belong to user '{$this->serviceUserId}'"); + } + // convert collection to a native type if needed + if (!($collection instanceof Collection)) { + $nativeCollection = new Collection(); + $nativeCollection->jsonDeserialize($collection->jsonSerialize()); + } else { + $nativeCollection = clone $collection; + } + // modify collection in store + $result = $this->store->collectionModify($this->serviceTenantId, $this->serviceUserId, $nativeCollection); + return $result; + } + + public function collectionDestroy(string|int $id): bool { + // validate id + if (!is_string($id)) { + throw new InvalidParameterException("Invalid: Collection identifier '$id' is not valid"); + } + // validate ownership + if ($this->collectionExtant($id) === false) { + throw new InvalidParameterException("Invalid: Collection identifier '$id' does not exist or does not belong to user '{$this->serviceUserId}'"); + } + // destroy collection in store + if ($this->store->collectionDestroyById($this->serviceTenantId, $this->serviceUserId, $id)) { + unset($this->serviceCollectionCache[$id]); + return true; + } + return false; + } + + public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $options = null): array { + // validate id + if (!is_string($collection)) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); + } + // validate ownership + if ($this->collectionExtant($collection) === false) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); + } + // retrieve entities from store + $entries = $this->store->entityList($this->serviceTenantId, $this->serviceUserId, $collection, $filter, $sort, $range, $options); + return $entries ?? []; + } + + + public function entityListFilter(): Filter { + return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []); + } + + public function entityListSort(): Sort { + return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []); + } + + public function entityListRange(RangeType $type): IRange { + // validate type + if ($type !== RangeType::TALLY) { + throw new InvalidParameterException("Invalid: Entity range of type '{$type->value}' is not valid"); + } + return new RangeTally(); + } + + public function entityExtant(string|int $collection, string|int ...$identifiers): array { + // validate id + if (!is_string($collection)) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); + } + // validate ownership + if ($this->collectionExtant($collection) === false) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); + } + // retrieve entity status from store + return $this->store->entityExtant($collection, ...$identifiers); + } + + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): array { + // validate id + if (!is_string($collection)) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); + } + // validate ownership + if ($this->collectionExtant($collection) === false) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); + } + // retrieve entity delta from store + return $this->store->chronicleReminisce($this->serviceTenantId, $this->serviceUserId,$collection, $signature); + } + + public function entityFetch(string|int $collection, string|int ...$identifiers): array { + // validate id + if (!is_string($collection)) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); + } + // validate ownership + if ($this->collectionExtant($collection) === false) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); + } + // retrieve entity from store + $entries = $this->store->entityFetch($this->serviceTenantId, $this->serviceUserId, $collection, ...$identifiers); + return $entries ?? []; + } + + public function entityFresh(): Entity { + return new Entity(); + } + + public function entityCreate(string|int $collection, IEntityMutable $entity, array $options): Entity { + // validate collection identifier + if (!is_string($collection)) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); + } + // validate collection extant and ownership + if ($this->collectionExtant($collection) === false) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); + } + // convert entity to a native type if needed + if (!($entity instanceof Entity)) { + $nativeEntity = $this->entityFresh(); + $nativeEntity->jsonDeserialize($entity->jsonSerialize()); + } else { + $nativeEntity = clone $entity; + } + // create entity in store (store will handle userId and collection) + $result = $this->store->entityCreate($this->serviceTenantId, $this->serviceUserId, $collection, $nativeEntity); + + return $result; + } + + public function entityModify(string|int $collection, string|int $identifier, IEntityMutable $entity): Entity { + // validate collection identifier + if (!is_string($collection)) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); + } + // validate entity identifier + if (!is_string($identifier)) { + throw new InvalidParameterException("Invalid: Entity identifier '$identifier' is not valid"); + } + // validate collection extant and ownership + if ($this->collectionExtant($collection) === false) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); + } + // validate entity extant and ownership + $extant = $this->store->entityExtant($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); + if (!isset($extant[$identifier]) || $extant[$identifier] === false) { + throw new InvalidParameterException("Invalid: Entity identifier '$identifier' does not exist or does not belong to collection '$collection' or user '{$this->serviceUserId}'"); + } + + // convert entity to a native type if needed + if (!($entity instanceof Entity)) { + $nativeEntity = $this->entityFresh(); + $nativeEntity->jsonDeserialize($entity->jsonSerialize()); + } else { + $nativeEntity = clone $entity; + } + + // modify entity in store + $result = $this->store->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $nativeEntity); + + return $result; + } + + public function entityDestroy(string|int $collection, string|int $identifier): IEntityBase { + // validate collection identifier + if (!is_string($collection)) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); + } + // validate entity identifier + if (!is_string($identifier)) { + throw new InvalidParameterException("Invalid: Entity identifier '$identifier' is not valid"); + } + // validate collection extant and ownership + if ($this->collectionExtant($collection) === false) { + throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); + } + // validate entity extant and ownership + $extant = $this->store->entityExtant($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); + if (!isset($extant[$identifier]) || $extant[$identifier] === false) { + throw new InvalidParameterException("Invalid: Entity identifier '$identifier' does not exist or does not belong to collection '$collection' or user '{$this->serviceUserId}'"); + } + // fetch entity before destruction to return it + $entities = $this->store->entityFetch($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); + $entity = $entities[$identifier] ?? $this->entityFresh(); + + // destroy entity in store + $this->store->entityDestroyById($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); + + return $entity; + } + +} diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php new file mode 100644 index 0000000..b4a577f --- /dev/null +++ b/lib/Providers/Provider.php @@ -0,0 +1,182 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\PeopleProviderLocal\Providers; + +use Psr\Container\ContainerInterface; +use KTXF\People\Provider\IProviderBase; +use KTXF\People\Service\IServiceBase; +use KTXF\Resource\Provider\ProviderInterface; +use KTXM\PeopleProviderLocal\Providers\Personal\PersonalService; +use KTXM\PeopleProviderLocal\Providers\Shared\SharedService; + +class Provider implements IProviderBase, ProviderInterface { + + protected const PROVIDER_ID = 'default'; + protected const PROVIDER_LABEL = 'Default People Provider'; + protected const PROVIDER_DESCRIPTION = 'Provides local people storage'; + protected const PROVIDER_ICON = 'user'; + + protected array $providerAbilities = [ + self::CAPABILITY_SERVICE_LIST => true, + self::CAPABILITY_SERVICE_FETCH => true, + self::CAPABILITY_SERVICE_EXTANT => true, + ]; + private ?array $servicesCache = []; + + public function __construct( + private readonly ContainerInterface $container, + ) {} + + public function jsonSerialize(): mixed + { + return $this->toJson(); + } + + public function toJson(): array + { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_ID => self::PROVIDER_ID, + self::JSON_PROPERTY_LABEL => self::PROVIDER_LABEL, + self::JSON_PROPERTY_CAPABILITIES => $this->providerAbilities, + ]; + } + + public function type(): string + { + return self::TYPE_PEOPLE; + } + + public function identifier(): string + { + return self::PROVIDER_ID; + } + + public function label(): string { + return self::PROVIDER_LABEL; + } + + public function description(): string + { + return self::PROVIDER_DESCRIPTION; + } + + public function icon(): string + { + return self::PROVIDER_ICON; + } + + public function capable(string $value): bool + { + return !empty($this->providerAbilities[$value]); + } + + public function capabilities(): array + { + return $this->providerAbilities; + } + + public function id(): string + { + return self::PROVIDER_ID; + } + + public function serviceList(string $tenantId, string $userId, array $filter = []): array + { + // if no filter is provided, return all services + if ($filter === []) { + $filter = ['personal', 'shared']; + } + // check if services are cached + if (in_array('personal', $filter, true) && !isset($this->servicesCache[$userId]['personal'])) { + $this->servicesCache[$userId]['personal'] = $this->serviceInstancePersonal($tenantId, $userId); + } + /* + if (in_array('shared', $filter, true) && !isset($this->servicesCache[$userId]['shared'])) { + $this->servicesCache[$userId]['shared'] = $this->serviceInstanceShared($tenantId, $userId); + } + */ + // return requested services + return array_intersect_key($this->servicesCache[$userId],array_flip($filter)); + } + + /** + * construct service object instance + * + * @since 1.0.0 + * + * @return PersonalService blank service instance + */ + protected function serviceInstancePersonal(string $tenantId, string $userId): PersonalService { + $service = $this->container->get(PersonalService::class); + $service->init($tenantId, $userId); + return $service; + } + + /** + * construct service object instance + * + * @since 1.0.0 + * + * @return SharedService blank service instance + */ + protected function serviceInstanceShared(string $tenantId, string $userId): SharedService { + $service = $this->container->get(SharedService::class); + $service->init($tenantId, $userId); + return $service; + } + + /** + * Determine if any services are configured for a specific user + * + * @since 1.0.0 + * + * @inheritdoc + */ + public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array { + $data = []; + foreach ($identifiers as $id) { + $data[$id] = match ($id) { + 'personal' => true, + //'shared' => true, + default => false, + }; + } + return $data; + } + + /** + * Retrieve a service with a specific identifier + * + * @since 1.0.0 + * + * @inheritdoc + */ + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase { + + // check if services are cached + if (isset($this->servicesCache[$userId][$identifier])) { + return $this->servicesCache[$userId][$identifier]; + } + // convert to service object + if ($identifier === 'personal') { + $this->servicesCache[$userId][$identifier] = $this->serviceInstancePersonal($tenantId, $userId); + } + /* + if ($identifier === 'shared') { + $this->servicesCache[$userId][$identifier] = $this->serviceInstanceShared($tenantId, $userId); + } + */ + + return $this->servicesCache[$userId][$identifier]; + + } + +} diff --git a/lib/Providers/Shared/Collection.php b/lib/Providers/Shared/Collection.php new file mode 100644 index 0000000..76ad78b --- /dev/null +++ b/lib/Providers/Shared/Collection.php @@ -0,0 +1,342 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\PeopleProviderLocal\Providers\Shared; + +use KTXF\People\Collection\CollectionContent; +use KTXF\People\Collection\CollectionPermissions; +use KTXF\People\Collection\CollectionRoles; +use KTXF\People\Collection\ICollectionMutable; +use KTXM\PeopleProviderLocal\Store\Personal\CollectionEntry; +use Override; + +class Collection implements ICollectionMutable { + + private ?string $userId = null; + private ?int $collectionShareId = null; + private ?string $collectionShareOwner = null; + private ?int $collectionSharePermissions = null; + private string $providerId = 'default'; + private string $serviceId = 'shared'; + private ?int $collectionId = null; + private ?string $collectionUuid = null; + private ?string $collectionLabel = null; + private ?string $collectionDescription = null; + private ?int $collectionPriority = null; + private ?bool $collectionVisibility = null; + private ?string $collectionColor = null; + private bool $collectionEnabled = true; + private ?string $collectionSignature = null; + private array $collectionPermissions = []; + private array $collectionAttributes = [ + 'roles' => [ + CollectionRoles::Individual->value => true, + ], + 'contents' => [ + CollectionContent::Individual->value => true, + CollectionContent::Organization->value => true, + CollectionContent::Group->value => true, + ], + ]; + + public function jsonSerialize(): mixed { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_PROVIDER => $this->providerId, + self::JSON_PROPERTY_SERVICE => $this->serviceId, + self::JSON_PROPERTY_IN => null, + self::JSON_PROPERTY_ID => $this->collectionId, + self::JSON_PROPERTY_UUID => $this->collectionUuid, + self::JSON_PROPERTY_LABEL => $this->collectionLabel, + self::JSON_PROPERTY_DESCRIPTION => $this->collectionDescription, + self::JSON_PROPERTY_PRIORITY => $this->collectionPriority, + self::JSON_PROPERTY_VISIBILITY => $this->collectionVisibility, + self::JSON_PROPERTY_COLOR => $this->collectionColor, + self::JSON_PROPERTY_ENABLED => $this->collectionEnabled, + self::JSON_PROPERTY_SIGNATURE => $this->collectionSignature, + self::JSON_PROPERTY_PERMISSIONS => [$this->userId => $this->collectionPermissions], + self::JSON_PROPERTY_ROLES => $this->collectionAttributes['roles'] ?? [], + self::JSON_PROPERTY_CONTENTS => $this->collectionAttributes['contents'] ?? [], + ]; + } + + public function jsonDeserialize(array $data): static + { + $this->collectionId = $data[self::JSON_PROPERTY_ID] ?? null; + $this->collectionUuid = $data[self::JSON_PROPERTY_UUID] ?? null; + $this->collectionLabel = $data[self::JSON_PROPERTY_LABEL] ?? null; + $this->collectionDescription = $data[self::JSON_PROPERTY_DESCRIPTION] ?? null; + $this->collectionPriority = $data[self::JSON_PROPERTY_PRIORITY] ?? null; + $this->collectionVisibility = $data[self::JSON_PROPERTY_VISIBILITY] ?? null; + $this->collectionColor = $data[self::JSON_PROPERTY_COLOR] ?? null; + $this->collectionEnabled = $data[self::JSON_PROPERTY_ENABLED] ?? true; + $this->collectionSignature = $data[self::JSON_PROPERTY_SIGNATURE] ?? null; + + return $this; + } + + public function fromStore(CollectionEntry $data): self { + $this->collectionShareOwner = $data->getUserId(); + $this->collectionId = $data->getId(); + $this->collectionUuid = $data->getUri(); + $this->collectionLabel = $data->getDisplayname(); + $this->collectionDescription = $data->getDescription(); + $this->collectionSignature = $data->getSynctoken(); + return $this; + } + + public function toStore(): CollectionEntry { + $data = new CollectionEntry(); + if ($this->collectionId !== null) { + $data->setId($this->collectionId); + } + $data->setUserId($this->userId); + $data->setUri($this->collectionUuid); + $data->setDisplayname($this->collectionLabel); + $data->setDescription($this->collectionDescription); + return $data; + } + + public function fromShareStore(array $data): self { + + if (empty($data['principaluri']) || !str_starts_with($data['principaluri'], 'principals/users/')) { + throw new \InvalidArgumentException('Share data must contain a principaluri'); + } + $this->userId = substr($data['principaluri'], 17); + $this->collectionShareId = $data['id'] ?? null; + $this->collectionSharePermissions = $data['access'] ?? 0; + $this->collectionPermissions[CollectionPermissions::View->value] = true; + if ($this->collectionSharePermissions === 2) { + $this->collectionPermissions[CollectionPermissions::Create->value] = true; + $this->collectionPermissions[CollectionPermissions::Modify->value] = true; + $this->collectionPermissions[CollectionPermissions::Destroy->value] = true; + } + return $this; + } + + /** + * Unique identifier of the service this collection belongs to + * + * @since 2025.05.01 + */ + public function in(): null { + return null; + } + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or collection1 or anything else) + * + * @since 2025.05.01 + */ + public function id(): int { + return $this->collectionShareId; + } + + /** + * Lists all supported attributes + * + * @since 2025.05.01 + * + * @return array + */ + public function attributes(): array { + return $this->collectionAttributes; + } + + /** + * Unique universal identifier + * + * @since 2025.05.01 + */ + public function uuid(): string { + return $this->collectionUuid; + } + + /** + * Gets the signature of this collection + * + * @since 2025.05.01 + */ + public function signature(): ?string { + return $this->collectionSignature; + } + + /** + * Gets the roles of this collection + * + * @since 2025.05.01 + * + * @return array + */ + public function roles(): array { + return $this->collectionAttributes['roles'] ?? []; + } + + /** + * Checks if this collection supports the given role + * + * @since 2025.05.01 + */ + public function role(CollectionRoles $role): bool { + return $this->collectionAttributes['roles'][$role->value] ?? false; + } + + /** + * Gets the content types of this collection + * + * @since 2025.05.01 + * + * @return array + */ + public function contents(): array { + return $this->collectionAttributes['content'] ?? []; + } + + /** + * Checks if this collection contains the given content type + * + * @since 2025.05.01 + */ + public function contains(CollectionContent $content): bool { + return $this->collectionAttributes['content'][$content->value] ?? false; + } + + /** + * Gets the active status of this collection + * + * @since 2025.05.01 + */ + public function getEnabled(): bool { + return (bool)$this->collectionEnabled; + } + + /** + * Sets the active status of this collection + * + * @since 2025.05.01 + */ + public function setEnabled(bool $value): self { + $this->collectionEnabled = $value; + return $this; + } + + /** + * Gets the active status of this collection + * + * @since 2025.05.01 + */ + public function getPermissions(): array { + return [$this->userId => $this->collectionPermissions]; + } + + /** + * Checks if this collection supports the given attribute + * + * @since 2025.05.01 + */ + public function hasPermission(CollectionPermissions $permission): bool { + return $this->collectionPermissions[$permission->value] ?? false; + } + + /** + * Gets the human friendly name of this collection (e.g. Personal Contacts) + * + * @since 2025.05.01 + */ + public function getLabel(): ?string { + return $this->collectionLabel; + } + + /** + * Sets the human friendly name of this collection (e.g. Personal Contacts) + * + * @since 2025.05.01 + */ + public function setLabel(string $value): self { + $this->collectionLabel = $value; + return $this; + } + + /** + * Gets the human friendly description of this collection + * + * @since 2025.05.01 + */ + public function getDescription(): ?string { + return $this->collectionDescription; + } + + /** + * Sets the human friendly description of this collection + * + * @since 2025.05.01 + */ + public function setDescription(?string $value): self { + $this->collectionDescription = $value; + return $this; + } + + /** + * Gets the priority of this collection + * + * @since 2025.05.01 + */ + public function getPriority(): ?int { + return $this->collectionPriority; + } + + /** + * Sets the priority of this collection + * + * @since 2025.05.01 + */ + public function setPriority(?int $value): self { + $this->collectionPriority = $value; + return $this; + } + + /** + * Gets the visibility of this collection + * + * @since 2025.05.01 + */ + public function getVisibility(): ?bool { + return $this->collectionVisibility; + } + + /** + * Sets the visibility of this collection + * + * @since 2025.05.01 + */ + public function setVisibility(?bool $value): self { + $this->collectionVisibility = $value; + return $this; + } + + /** + * Gets the color of this collection + * + * @since 2025.05.01 + */ + public function getColor(): ?string { + return $this->collectionColor; + } + + /** + * Sets the color of this collection + * + * @since 2025.05.01 + */ + public function setColor(?string $value): self { + $this->collectionColor = $value; + return $this; + } + +} diff --git a/lib/Providers/Shared/SharedService.php b/lib/Providers/Shared/SharedService.php new file mode 100644 index 0000000..e3a4276 --- /dev/null +++ b/lib/Providers/Shared/SharedService.php @@ -0,0 +1,583 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\PeopleProviderLocal\Providers\Shared; + +use KTXF\People\Collection\ICollectionMutable; +use KTXF\People\Entity\IEntityMutable; +use KTXF\People\Exceptions\InvalidParameterException; +use KTXF\People\Exceptions\UnauthorizedException; +use KTXF\People\Exceptions\UnsupportedException; +use KTXF\People\Filter\Filter; +use KTXF\People\Filter\IFilter; +use KTXF\People\Range\IRange; +use KTXF\People\Range\RangeTally; +use KTXF\People\Range\RangeType; +use KTXF\People\Service\IServiceBase; +use KTXF\People\Service\IServiceCollectionMutable; +use KTXF\People\Service\IServiceEntityMutable; +use KTXF\People\Sort\ISort; +use KTXF\People\Sort\Sort; +use KTXM\PeopleProviderLocal\Providers\Personal\Entity; + +class SharedService implements IServiceBase, IServiceCollectionMutable, IServiceEntityMutable { + + protected const SERVICE_ID = 'shared'; + protected const SERVICE_LABEL = 'Shared Contacts Service'; + protected const SERVICE_PROVIDER = 'default'; + + protected array $serviceCollectionCache = []; + protected array $serviceSharesCache = []; + protected ?string $serviceTenantId = null; + protected ?string $serviceUserId = null; + protected ?bool $serviceEnabled = true; + + protected array $serviceAbilities = [ + self::CAPABILITY_COLLECTION_LIST => true, + self::CAPABILITY_COLLECTION_LIST_FILTER => [ + 'id' => 'a:10:64:192', + 'label' => 's:100:256:771', + 'description' => 's:100:256:771', + ], + self::CAPABILITY_COLLECTION_LIST_SORT => [ + 'label', + 'description' + ], + self::CAPABILITY_COLLECTION_EXTANT => true, + self::CAPABILITY_COLLECTION_FETCH => true, + self::CAPABILITY_COLLECTION_CREATE => true, + self::CAPABILITY_COLLECTION_MODIFY => true, + self::CAPABILITY_COLLECTION_DESTROY => true, + self::CAPABILITY_ENTITY_LIST => true, + self::CAPABILITY_ENTITY_LIST_FILTER => [ + '*' => 's:200:256:771', + 'uri' => 's:200:256:771', + 'label' => 's:200:256:771', + 'phone' => 's:200:256:771', + 'email' => 's:200:256:771', + 'location' => 's:200:256:771' + ], + self::CAPABILITY_ENTITY_LIST_SORT => [ + 'label', + 'phone', + 'email', + 'location' + ], + self::CAPABILITY_ENTITY_LIST_RANGE => [ + 'tally' => ['absolute', 'relative'] + ], + self::CAPABILITY_ENTITY_DELTA => true, + self::CAPABILITY_ENTITY_EXTANT => true, + self::CAPABILITY_ENTITY_FETCH => true, + self::CAPABILITY_ENTITY_CREATE => true, + self::CAPABILITY_ENTITY_MODIFY => true, + self::CAPABILITY_ENTITY_DESTROY => true, + self::CAPABILITY_ENTITY_COPY => true, + self::CAPABILITY_ENTITY_MOVE => true, + ]; + + public function __construct( + private Store $store, + //private SharingMapper $sharingStore, + ) {} + + public function jsonSerialize(): mixed { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_PROVIDER => self::SERVICE_PROVIDER, + self::JSON_PROPERTY_ID => self::SERVICE_ID, + self::JSON_PROPERTY_LABEL => self::SERVICE_LABEL, + self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, + self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, + ]; + } + + public function init(string $tenantId, string $userId): self { + $this->serviceTenantId = $tenantId; + $this->serviceUserId = $userId; + return $this; + } + + /** + * Confirms if specific capability is supported + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function capable(string $value): bool { + if (isset($this->serviceAbilities[$value])) { + return (bool)$this->serviceAbilities[$value]; + } + return false; + } + + /** + * Lists all supported capabilities + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function capabilities(): array { + return $this->serviceAbilities; + } + + /** + * Unique identifier of the provider this service belongs to + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function in(): string{ + return self::SERVICE_PROVIDER; + } + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or service1 or anything else) + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function id(): string { + return self::SERVICE_ID; + } + + /** + * Gets the localized human friendly name of this service (e.g. ACME Company Mail Service) + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function getLabel(): string { + return self::SERVICE_LABEL; + } + + /** + * Gets the active status of this service + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function getEnabled(): bool { + return (bool)$this->serviceEnabled; + } + + /** + * List of accessible collection + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function collectionList(?IFilter $filter = null, ?ISort $sort = null): array { + /* + $shareEntries = $this->listShareEntries(); + foreach ($shareEntries as $key => $shareEntry) { + $collectionEntry = $this->store->collectionFetch('system', $shareEntry['resourceid']); + $collection = new Collection(); + $collection->fromStore($collectionEntry)->fromShareStore($shareEntry); + $list[$collection->id()] = $collection; + $this->serviceCollectioncache[$collection->id()] = $collection; + } + */ + return $list ?? []; + } + + /** + * Returns a filter for collection list + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function collectionListFilter(): Filter { + return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); + } + + /** + * Returns a sort for collection list + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function collectionListSort(): Sort { + return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); + } + + /** + * Confirms if a collection exists + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function collectionExtant(string|int $id): bool { + // validate id + if (!is_numeric($id)) { + throw new InvalidParameterException("Invalid: Collection identifier '$id' is not valid"); + } + $id = (int)$id; + // determine if collection is cached + if (isset($this->serviceCollectioncache[$id])) { + return true; + } + if ($this->fetchShareEntry($id) !== null) { + return true; + } + return false; + } + + /** + * Fetches details about a specific collection + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function collectionFetch(string|int $id): ?Collection { + // validate access + if ($this->collectionExtant($id) === false) { + throw new UnauthorizedException("Unauthorized: User '{$this->serviceUserId}' does not have access to collection '$id'"); + } + $id = (int)$id; + // determine if collection is cached + if (isset($this->serviceCollectioncache[$id])) { + return $this->serviceCollectioncache[$id]; + } + // retrieve share data + $shareEntry = $this->fetchShareEntry($id); + // retrieve collection data + $collectionEntry = $this->store->collectionFetch('system', $shareEntry['resourceid']); + if ($collectionEntry !== null) { + $collection = new Collection(); + $collection->fromStore($collectionEntry)->fromShareStore($shareEntry); + $this->serviceCollectioncache[$collection->id()] = $collection; + return $collection; + } + return null; + } + + /** + * Creates a new collection at the specified location + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function collectionCreate(string|int $location, ICollectionMutable $collection, array $options): Collection { + throw new UnsupportedException("Unsupported: Shared service does not support collection creation"); + } + + /** + * Modifies an existing collection + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function collectionModify(string|int $id, ICollectionMutable $collection): Collection { + // validate access + if ($this->collectionExtant($id) === false) { + throw new UnauthorizedException("Unauthorized: User '{$this->serviceUserId}' does not have access to collection '$id'"); + } + $id = (int)$id; + // convert collection to a native type if needed + if (!($collection instanceof Collection)) { + $nativeCollection = new Collection(); + $nativeCollection->fromJson($collection->toJson()); + } else { + $nativeCollection = clone $collection; + } + // convert to store type and force user id + $storeEntry = $nativeCollection->toStore(); + $storeEntry->setUserId($this->serviceUserId); + // modify collection in store + $storeEntry = $this->store->collectionModify($storeEntry); + $nativeCollection->fromStore($storeEntry); + return $nativeCollection; + } + + /** + * Destroys a collection + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function collectionDestroy(string|int $id): bool { + // validate access + if ($this->collectionExtant($id) === false) { + throw new UnauthorizedException("Unauthorized: User '{$this->serviceUserId}' does not have access to collection '$id'"); + } + $id = (int)$id; + // destroy collection in store + if ($this->store->collectionDestroyById($this->serviceUserId, $id)) { + unset($this->serviceCollectioncache[$id]); + return true; + } + return false; + } + + /** + * Lists all entities in a specific collection + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $options = null): array { + // validate collection access + if ($this->collectionExtant($collection) === false) { + throw new UnauthorizedException("Unauthorized: User '{$this->serviceUserId}' does not have access to collection '$collection'"); + } + $collection = (int)$collection; + // retrieve share entry + $shareEntry = $this->fetchShareEntry($collection); + // retrieve entity entries + $entries = $this->store->entityList($shareEntry['resourceid'], $filter, $sort, $range, $options); + foreach ($entries as $key => $entry) { + $entity = new Entity(); + $entity->fromStore($entry); + $entities[$key] = $entity; + } + return $entities ?? []; + } + + /** + * Returns a filter for entity list + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityListFilter(): Filter { + return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []); + } + + /** + * Returns a sort for entity list + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityListSort(): Sort { + return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []); + } + + /** + * Returns a sort for entity list + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityListRange(RangeType $type): IRange { + // validate type + if ($type !== RangeType::TALLY) { + throw new InvalidParameterException("Invalid: Entity range of type '{$type->value}' is not valid"); + } + return new RangeTally(); + } + + /** + * Confirms if entity(ies) exist in a collection + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityExtant(string|int $collection, string|int ...$identifiers): array { + // validate access + if ($this->collectionExtant($collection) === false) { + throw new UnauthorizedException("Unauthorized: User '{$this->serviceUserId}' does not have access to collection '$collection'"); + } + $collection = (int)$collection; + // retrieve share entry + $shareEntry = $this->fetchShareEntry($collection); + // retrieve entity status + return $this->store->entityExtant($shareEntry['resourceid'], $identifiers); + } + + /** + * Lists of all changes from a specific token + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): array { + // validate access + if ($this->collectionExtant($collection) === false) { + throw new UnauthorizedException("Unauthorized: User '{$this->serviceUserId}' does not have access to collection '$collection'"); + } + $collection = (int)$collection; + // retrieve share entry + $shareEntry = $this->fetchShareEntry($collection); + // retrieve entity delta from store + return $this->store->chronicleReminisce($shareEntry['resourceid'], $signature); + } + + /** + * Retrieves details about a specific entity(ies) + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityFetch(string|int $collection, string|int ...$identifiers): array { + // validate collection access + if ($this->collectionExtant($collection) === false) { + throw new UnauthorizedException("Unauthorized: User '{$this->serviceUserId}' does not have access to collection '$collection'"); + } + $collection = (int)$collection; + // retrieve share entry + $shareEntry = $this->fetchShareEntry($collection); + // retrieve entity entry + $entries = $this->store->entityFetch($shareEntry['resourceid'], $identifiers); + foreach ($entries as $key => $entry) { + $entity = new Entity(); + $entity->fromStore($entry); + $entities[$key] = $entity; + } + return $entities ?? []; + } + + /** + * Creates a fresh entity of the specified type + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityFresh(): Entity { + return new Entity(); + } + + /** + * Creates a new entity in the specified collection + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityCreate(string|int $collection, IEntityMutable $entity, array $options): Entity { + // validate collection access + if ($this->collectionExtant($collection) === false) { + throw new UnauthorizedException("Unauthorized: User '{$this->serviceUserId}' does not have access to collection '$collection'"); + } + $collection = (int)$collection; + // retrieve share entry + $shareEntry = $this->fetchShareEntry($collection); + // convert enity to a native type if needed + if (!($entity instanceof Entity)) { + $nativeEntity = $this->entityFresh(); + $nativeEntity->fromJson($entity->toJson()); + } else { + $nativeEntity = clone $entity; + } + // convert to store type and force address book id + $storeEntry = $nativeEntity->toStore(); + $storeEntry->setAddressbookid($shareEntry['resourceid']); + $storeEntry->setLastmodified(time()); + if (isset($options['source']) && $options['source'] === 'dav' && isset($options['uri']) && !empty($options['uri'])) { + $storeEntry->setUri($options['uri']); + } + // create entry in store + $storeEntry = $this->store->entityCreate($storeEntry); + $nativeEntity->fromStore($storeEntry); + + return $nativeEntity; + } + + /** + * Modifies an existing entity in the specified collection + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityModify(string|int $collection, string|int $identifier, IEntityMutable $entity): Entity { + // validate collection access + if ($this->collectionExtant($collection) === false) { + throw new UnauthorizedException("Unauthorized: User '{$this->serviceUserId}' does not have access to collection '$collection'"); + } + $collection = (int)$collection; + // validate entity identifier + if (empty($identifier) || !is_numeric($identifier)) { + throw new InvalidParameterException("Invalid: Entity identifier '$identifier' is not valid"); + } + $identifier = (int)$identifier; + // retrieve share entry + $shareEntry = $this->fetchShareEntry($collection); + // validate entity extant and ownership + $extant = $this->store->entityExtant($collection, [$identifier]); + if (!isset($extant[$identifier]) || $extant[$identifier] === false) { + throw new InvalidParameterException("Invalid: Entity identifier '$identifier' does not exist or does not belong to collection '$collection' or user '{$this->serviceUserId}'"); + } + // convert enity to a native type if needed + if (!($entity instanceof Entity)) { + $nativeEntity = $this->entityFresh(); + $nativeEntity->fromJson($entity->toJson()); + } else { + $nativeEntity = clone $entity; + } + + // convert to store type and force address book id + $storeEntry = $nativeEntity->toStore(); + $storeEntry->setId($identifier); + $storeEntry->setAddressbookid($shareEntry['resourceid']); + $storeEntry->setLastmodified(time()); + // modify entry in store + $storeEntry = $this->store->entityModify($shareEntry['resourceid'], $storeEntry); + $nativeEntity->fromStore($storeEntry); + + return $nativeEntity; + } + + /** + * Destroys an existing entity in the specified collection + * + * @since 1.0.0 + * + * @inheritDoc + */ + public function entityDestroy(string|int $collection, string|int $identifier): IEntityMutable { + // validate collection access + if ($this->collectionExtant($collection) === false) { + throw new UnauthorizedException("Unauthorized: User '{$this->serviceUserId}' does not have access to collection '$collection'"); + } + $collection = (int)$collection; + // retrieve share entry + $shareEntry = $this->fetchShareEntry($collection); + // validate entity identifier + if (empty($identifier) || !is_numeric($identifier)) { + throw new InvalidParameterException("Invalid: Entity identifier '$identifier' is not valid"); + } + $identifier = (int)$identifier; + // validate entity extant and ownership + $extant = $this->store->entityExtant($collection, [$identifier]); + if (!isset($extant[$identifier]) || $extant[$identifier] === false) { + throw new InvalidParameterException("Invalid: Entity identifier '$identifier' does not exist or does not belong to collection '$collection' or user '{$this->serviceUserId}'"); + } + // destroy entry in store + $storeEntry = $this->store->entityDestroyById($shareEntry['resourceid'], $identifier); + $nativeEntity = $this->entityFresh(); + $nativeEntity->fromStore($storeEntry); + + return $nativeEntity; + } + +} diff --git a/lib/Store/Personal/Store.php b/lib/Store/Personal/Store.php new file mode 100644 index 0000000..7cdc684 --- /dev/null +++ b/lib/Store/Personal/Store.php @@ -0,0 +1,718 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\PeopleProviderLocal\Store\Personal; + +use KTXC\Db\DataStore; +use KTXF\Resource\Filter\Filter; +use KTXF\Resource\Filter\FilterComparisonOperator; +use KTXF\Resource\Filter\FilterConjunctionOperator; +use KTXF\Resource\Range\Range; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\Sort; +use KTXF\Utile\UUID; +use KTXM\PeopleProviderLocal\Providers\Personal\Collection; +use KTXM\PeopleProviderLocal\Providers\Personal\Entity; + +class Store { + + protected string $_CollectionTable = 'people_provider_local_collection'; + protected string $_CollectionClass = 'KTXM\PeopleProviderLocal\Providers\Personal\Collection'; + protected string $_EntityTable = 'people_provider_local_entity'; + protected string $_EntityClass = 'KTXM\PeopleProviderLocal\Providers\Personal\Entity'; + protected string $_ChronicleTable = 'people_provider_local_chronicle'; + + protected array $_CollectionFilterAttributeMap = [ + 'id' => 'cid', + 'label' => 'label', + 'description' => 'description', + ]; + + protected array $_CollectionFilterAttributeComparatorDefault = [ + 'id' => FilterComparisonOperator::IN, + 'label' => FilterComparisonOperator::LIKE, + 'description' => FilterComparisonOperator::LIKE, + ]; + + protected array $_EntityFilterAttributeMap = [ + 'id' => 'eid', + 'label' => 'data.name.givenName', + 'email' => 'data.emails.address', + 'phone' => 'data.phones.number', + 'organization' => 'data.organization.name', + 'tags' => 'data.tags', + ]; + + public function __construct( + protected readonly DataStore $_store + ) { } + + protected function constructFilter(array $map, Filter $filter): array { + $mongoFilter = []; + + foreach ($filter->conditions() as $entry) { + if (!isset($map[$entry['attribute']])) { + continue; + } + + $attribute = $map[$entry['attribute']]; + $value = $entry['value']; + $comparator = $entry['comparator'] ?? FilterComparisonOperator::EQ; + + $condition = match ($comparator) { + FilterComparisonOperator::EQ => $value, + FilterComparisonOperator::NEQ => ['$ne' => $value], + FilterComparisonOperator::GT => ['$gt' => $value], + FilterComparisonOperator::GTE => ['$gte' => $value], + FilterComparisonOperator::LT => ['$lt' => $value], + FilterComparisonOperator::LTE => ['$lte' => $value], + FilterComparisonOperator::IN => ['$in' => is_array($value) ? $value : [$value]], + FilterComparisonOperator::NIN => ['$nin' => is_array($value) ? $value : [$value]], + FilterComparisonOperator::LIKE => ['$regex' => $value, '$options' => 'i'], + FilterComparisonOperator::NLIKE => ['$not' => ['$regex' => $value, '$options' => 'i']], + default => $value + }; + + if (isset($mongoFilter[$attribute])) { + // Handle conjunction + if ($entry['conjunction'] === FilterConjunctionOperator::OR) { + $mongoFilter['$or'][] = [$attribute => $condition]; + } else { + // AND conjunction - merge with existing + if (is_array($mongoFilter[$attribute]) && !isset($mongoFilter[$attribute]['$and'])) { + $mongoFilter[$attribute] = ['$and' => [$mongoFilter[$attribute], $condition]]; + } else { + $mongoFilter[$attribute] = $condition; + } + } + } else { + $mongoFilter[$attribute] = $condition; + } + } + + return $mongoFilter; + } + + protected function constructSort(array $map, Sort $sort): array { + $mongoSort = []; + + foreach ($sort->conditions() as $entry) { + if (!isset($map[$entry['attribute']])) { + continue; + } + + $attribute = $map[$entry['attribute']]; + $direction = $entry['direction'] ? 1 : -1; + $mongoSort[$attribute] = $direction; + } + + return $mongoSort; + } + + /** + * retrieve collections from data store + * + * @since Release 1.0.0 + * + * @param Filter $filter filter options + * @param Sort $sort sort options + * + * @return array + */ + public function collectionList(string $tenantId, string $userId, ?Filter $filter = null, ?Sort $sort = null): array { + $query = ['tid' => $tenantId, 'uid' => $userId]; + + // Apply filter if provided + if ($filter !== null) { + $filterConditions = $this->constructFilter($this->_CollectionFilterAttributeMap, $filter); + $query = array_merge($query, $filterConditions); + } + + $options = []; + + // Apply sort if provided + if ($sort !== null) { + $sortConditions = $this->constructSort($this->_CollectionFilterAttributeMap, $sort); + $options['sort'] = $sortConditions; + } + + $cursor = $this->_store->selectCollection($this->_CollectionTable)->find($query, $options); + $list = []; + foreach ($cursor as $entry) { + $entry = (new Collection())->fromStore($entry); + $list[$entry->id()] = $entry; + } + return $list; + } + + /** + * confirm if collections exist in data store + * + * @since Release 1.0.0 + * + * @param string $userId user id + * @param string $id collection id + * + */ + public function collectionExtant(string $tenantId, string $userId, string $identifier): bool { + $cursor = $this->_store->selectCollection($this->_CollectionTable)->findOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'cid' => $identifier + ]); + return $cursor !== null; + } + + /** + * retrieve collection from data store + * + * @since Release 1.0.0 + * + * @param string $userId user identifier + * @param string $identifier collection identifier + * + * @return Collection + */ + public function collectionFetch(string $tenantId, string $userId, string $identifier): ?Collection { + $cursor = $this->_store->selectCollection($this->_CollectionTable)->findOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'cid' => $identifier + ]); + if ($cursor === null) { + return null; + } + $entry = (new Collection())->fromStore($cursor); + return $entry; + } + + /** + * fresh instance of a collection entity + * + * @since Release 1.0.0 + * + * @return Collection + */ + public function collectionFresh(): Collection { + return new $this->_CollectionClass; + } + + /** + * create a collection entry in the data store + * + * @since Release 1.0.0 + * + * @param string $userId user identifier + * @param Collection $entity + * + * @return Collection + */ + public function collectionCreate(string $tenantId, string $userId, Collection $entity): Collection { + // convert entity to store format + $data = $entity->toStore(); + // prepare data for creation + $data['tid'] = $tenantId; + $data['uid'] = $userId; + $data['cid'] = UUID::v4(); + $data['createdOn'] = date('c'); + $data['modifiedOn'] = $data['createdOn']; + // create entry + $result = $this->_store->selectCollection($this->_CollectionTable)->insertOne($data); + if ($result->getInsertedCount() === 1) { + $entity = new Collection(); + $entity->fromStore($data); + } + return $entity; + } + + /** + * modify a collection entry in the data store + * + * @since Release 1.0.0 + * + * @param string $userId user identifier + * @param Collection $entity + * + * @return Collection + */ + public function collectionModify(string $tenantId, string $userId, Collection $entity): Collection { + // convert entity to store format + $data = $entity->toStore(); + // prepare data for modification + $cid = $entity->id(); + $data['modifiedOn'] = date('c'); + unset($data['_id'], $data['tid'], $data['uid'], $data['cid']); + // modify entry + $this->_store->selectCollection($this->_CollectionTable)->updateOne( + ['tid' => $tenantId, 'uid' => $userId, 'cid' => $cid], + ['$set' => $data] + ); + return $entity; + } + + /** + * delete a collection entry from the data store + * + * @since Release 1.0.0 + * + * @param Collection $entity + * + * @return Collection + */ + public function collectionDestroy(string $tenantId, string $userId, Collection $entity): Collection { + return $this->collectionDestroyById($tenantId, $userId, $entity->id()) ? $entity : $entity; + } + + /** + * delete a collection entry from the data store by ID and user + * + * @since Release 1.0.0 + * + * @param string $userId user identifier + * @param string $collectionId collection identifier + * + * @return bool + */ + public function collectionDestroyById(string $tenantId, string $userId, string $collectionId): bool { + $result = $this->_store->selectCollection($this->_CollectionTable)->deleteOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'cid' => $collectionId + ]); + + return $result->getDeletedCount() === 1; + } + + /** + * retrieve entities from data store + * + * @since Release 1.0.0 + * + * @param string $collection collection identifier + * @param Filter $filter filter options + * @param Sort $sort sort options + * @param Range $range range options + * + * @return array of entities + */ + public function entityList(string $tenantId, string $userId, string $collectionId, ?Filter $filter = null, ?Sort $sort = null, ?Range $range = null, ?array $options = null): array { + $query = ['tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId]; + + // Apply filter if provided + if ($filter !== null) { + $filterConditions = $this->constructFilter($this->_EntityFilterAttributeMap, $filter); + $query = array_merge($query, $filterConditions); + } + + $findOptions = []; + + // Apply sort if provided + if ($sort !== null) { + $sortConditions = $this->constructSort($this->_EntityFilterAttributeMap, $sort); + $findOptions['sort'] = $sortConditions; + } + + // Apply range/pagination if provided + if ($range !== null && $range->type() === RangeType::TALLY) { + // For TALLY ranges, use position (skip) and tally (limit) + /** @var IRangeTally $rangeTally */ + $rangeTally = $range; + $findOptions['skip'] = $rangeTally->getPosition(); + $findOptions['limit'] = $rangeTally->getTally(); + } + + $cursor = $this->_store->selectCollection($this->_EntityTable)->find($query, $findOptions); + $list = []; + foreach ($cursor as $entry) { + $entity = (new Entity())->fromStore($entry); + $list[$entity->id()] = $entity; + } + return $list; + } + + /** + * confirm if entity(ies) exist in data store + * + * @since Release 1.0.0 + * + * @param string $collection collection identifier + * @param string ...$identifiers entity identifiers (eid UUID strings) + * + * @return array + */ + public function entityExtant(string $tenantId, string $userId, string $collectionId, string ...$identifiers): array { + + // Query for all entity IDs at once, but only retrieve the eid field (projection) + $cursor = $this->_store->selectCollection($this->_EntityTable)->find( + [ + 'tid' => $tenantId, + 'uid' => $userId, + 'cid' => $collectionId, + 'eid' => ['$in' => $identifiers] + ], + [ + 'projection' => ['eid' => 1, '_id' => 0] + ] + ); + + // Build flat array of found IDs + $found = []; + foreach ($cursor as $entry) { + $found[] = $entry['eid']; + } + + $result = array_fill_keys($found, true); + $result = array_merge($result, array_fill_keys(array_diff($identifiers, $found), false)); + + return $result; + } + + /** + * retrieve entity(ies) from data store + * + * @since Release 1.0.0 + * + * @param string $collection collection identifier + * @param string ...$identifiers entity identifiers (eid UUID strings) + * + * @return array + */ + public function entityFetch(string $tenantId, string $userId, string $collectionId, string ...$identifiers): array { + // Query for entities using eid field + $cursor = $this->_store->selectCollection($this->_EntityTable)->find([ + 'tid' => $tenantId, + 'uid' => $userId, + 'cid' => $collectionId, + 'eid' => ['$in' => $identifiers] + ]); + + $list = []; + foreach ($cursor as $entry) { + $entity = (new Entity())->fromStore($entry); + $list[$entity->id()] = $entity; + } + + return $list; + } + + /** + * fresh instance of a entity + * + * @since Release 1.0.0 + * + * @return Entity + */ + public function entityFresh(): Entity { + return new Entity(); + } + + /** + * create a entity entry in the data store + * + * @since Release 1.0.0 + * + * @param Entity $entity entity to create + * + * @return Entity + */ + /** + * create a entity entry in the data store + * + * @since Release 1.0.0 + * + * @param string $userId user identifier + * @param string $collection collection identifier + * @param Entity $entity entity to create + * + * @return Entity + */ + public function entityCreate(string $tenantId, string $userId, string $collectionId, Entity $entity): Entity { + // convert entity to store format + $data = $entity->toStore(); + // assign identifiers and timestamps + $data['tid'] = $tenantId; + $data['uid'] = $userId; + $data['cid'] = $collectionId; + $data['eid'] = UUID::v4(); + $data['createdOn'] = date('c'); + $data['createdBy'] = $userId; + $data['modifiedOn'] = $data['createdOn']; + $data['modifiedBy'] = $data['createdBy']; + + $result = $this->_store->selectCollection($this->_EntityTable)->insertOne($data); + + if ($result->getInsertedCount() === 1) { + $eid = $data['eid']; + $entity->fromStore(['eid' => $eid, 'tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId]); + // Chronicle the creation (operation 1) + $this->chronicleDocument($tenantId, $collectionId, $eid, 1); + } + + return $entity; + } + + /** + * modify a entity entry in the data store + * + * @since Release 1.0.0 + * + * @param string $userId user identifier + * @param string $collection collection identifier + * @param string $identifier entity identifier + * @param Entity $entity entity to modify + * + * @return Entity + */ + public function entityModify(string $tenantId, string $userId, string $collectionId, string $identifier, Entity $entity): Entity { + // convert entity to store format + $data = $entity->toStore(); + $data['modifiedOn'] = date('c'); + $data['modifiedBy'] = $userId; + // Remove identifiers from update data (they shouldn't change) + unset($data['_id'], $data['tid'], $data['uid'], $data['cid'], $data['eid']); + + $result = $this->_store->selectCollection($this->_EntityTable)->updateOne( + ['tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId, 'eid' => $identifier], + ['$set' => $data] + ); + + if ($result->getModifiedCount() > 0) { + // Chronicle the modification (operation 2) + $this->chronicleDocument($tenantId, $collectionId, $identifier, 2); + } + + return $entity; + } + + /** + * delete a entity from the data store + * + * @since Release 1.0.0 + * + * @param string $userId user identifier + * @param string $collection collection identifier + * @param Entity $entity entity to delete + * + * @return Entity + */ + public function entityDestroy(string $tenantId, string $userId, string $collectionId, Entity $entity): Entity { + $identifier = $entity->id(); + + $result = $this->_store->selectCollection($this->_EntityTable)->deleteOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'cid' => $collectionId, + 'eid' => $identifier + ]); + + if ($result->getDeletedCount() === 1) { + // Chronicle the deletion (operation 3) + $this->chronicleDocument($tenantId, $collectionId, $identifier, 3); + } + + return $entity; + } + + /** + * delete a entity from the data store by ID + * + * @since Release 1.0.0 + * + * @param string $userId user identifier + * @param string $collection collection identifier + * @param string $entityId entity identifier + * + * @return bool + */ + public function entityDestroyById(string $tenantId, string $userId, string $collectionId, string $identifier): bool { + $result = $this->_store->selectCollection($this->_EntityTable)->deleteOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'cid' => $collectionId, + 'eid' => $identifier + ]); + + if ($result->getDeletedCount() === 1) { + // Chronicle the deletion (operation 3) + $this->chronicleDocument($tenantId, $collectionId, $identifier, 3); + return true; + } + + return false; + } + + /** + * chronicle a operation to an entity to the data store + * + * @since Release 1.0.0 + * + * @param string $tid tenant identifier + * @param string $cid collection identifier + * @param string $eid entity identifier + * @param int $operation operation type (1 - Created, 2 - Modified, 3 - Deleted) + */ + private function chronicleDocument(string $tid, string $cid, string $eid, int $operation): void { + // retrieve current token from collection + $collection = $this->_store->selectCollection($this->_CollectionTable)->findOne([ + 'cid' => $cid + ], [ + 'projection' => ['signature' => 1, '_id' => 0] + ]); + + $signature = $collection['signature'] ?? 0; + + // document operation in chronicle + $this->_store->selectCollection($this->_ChronicleTable)->insertOne([ + 'tid' => $tid, + 'cid' => $cid, + 'eid' => $eid, + 'operation' => $operation, + 'signature' => $signature, + 'mutatedOn' => time(), + ]); + + // increment token atomically + $this->_store->selectCollection($this->_CollectionTable)->updateOne( + ['cid' => $cid], + ['$inc' => ['signature' => 1]] + ); + } + + /** + * reminisce operations to entities in data store + * + * @since Release 1.0.0 + * + * @param string $cid collection id + * @param bool $encode weather to encode the result + * + * @return int|float|string + */ + public function chronicleApex(string $tid, string $cid, bool $encode = true): int|float|string { + + // Use aggregation pipeline to find max signature + $cursor = $this->_store->selectCollection($this->_ChronicleTable)->aggregate([ + [ + '$match' => ['tid' => $tid, 'cid' => $cid] + ], + [ + '$group' => [ + '_id' => null, + 'maxToken' => ['$max' => '$signature'] + ] + ] + ]); + + $result = $cursor->toArray(); + $stampApex = !empty($result) ? ($result[0]['maxToken'] ?? 0) : 0; + + if ($encode) { + return base64_encode((string)max(0, $stampApex)); + } else { + return max(0, $stampApex); + } + + } + + /** + * reminisce operations to entities in data store + * + * @since Release 1.0.0 + * + * @param string $collection collection id + * @param string $signature encoded token + * + * @return array + */ + public function chronicleReminisce(string $tenantId, string $collectionId, string $signature): array { + + // retrieve apex signature + $tokenApex = $this->chronicleApex($tenantId, $collectionId, false); + // determine nadir signature + $tokenNadir = !empty($signature) ? base64_decode($signature) : ''; + $initial = !is_numeric($tokenNadir); + $tokenNadir = $initial ? 0 : (int)$tokenNadir; + + // Build aggregation pipeline to retrieve additions/modifications/deletions + $matchStage = [ + '$match' => [ + 'tid' => $tenantId, + 'cid' => $collectionId + ] + ]; + + // If not initial sync, filter by signature range + if (!$initial) { + $matchStage['$match']['signature'] = [ + '$gt' => $tokenNadir, + '$lte' => (int)$tokenApex + ]; + } + + $pipeline = [ + $matchStage, + [ + '$group' => [ + '_id' => '$eid', + 'operation' => ['$max' => '$operation'], + 'eid' => ['$first' => '$eid'] + ] + ] + ]; + + // For initial sync, exclude deleted entries + if ($initial) { + $pipeline[] = [ + '$match' => [ + 'operation' => ['$ne' => 3] + ] + ]; + } + + // define place holder + $chronicle = ['additions' => [], 'modifications' => [], 'deletions' => [], 'signature' => base64_encode((string)$tokenApex)]; + + // execute aggregation + $cursor = $this->_store->selectCollection($this->_ChronicleTable)->aggregate($pipeline); + + // process result + foreach ($cursor as $entry) { + switch ($entry['operation']) { + case 1: + $chronicle['additions'][] = $entry['eid']; + break; + case 2: + $chronicle['modifications'][] = $entry['eid']; + break; + case 3: + $chronicle['deletions'][] = $entry['eid']; + break; + } + } + + // return chronicle + return $chronicle; + + } + + /** + * delete chronicle entries for a specific collection(s) from data store + * + * @since Release 1.0.0 + * + * @param array $identifiers collection of identifiers + */ + private function chronicleExpungeByCollectionId(array $identifiers): void { + // Delete chronicle entries for the specified collection identifiers + $this->_store->selectCollection($this->_ChronicleTable)->deleteMany([ + 'cid' => ['$in' => $identifiers] + ]); + } + +}