From 76f1115f7ad68cda8b1eddf92007fd5d54f23fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Buli=C5=84ski?= Date: Mon, 4 Apr 2022 16:09:06 +0000 Subject: [PATCH] Feature/search --- .env.example | 4 - app/Casts/MetadataValue.php | 44 + .../DiscountSearch.php | 6 +- app/{SearchTypes => Criteria}/ItemSearch.php | 6 +- app/Criteria/MetadataPrivateSearch.php | 35 + app/Criteria/MetadataSearch.php | 26 + app/{SearchTypes => Criteria}/OrderSearch.php | 6 +- .../PermissionSearch.php | 6 +- .../ProductSetSearch.php | 6 +- .../RoleAssignableSearch.php | 6 +- app/{SearchTypes => Criteria}/RoleSearch.php | 6 +- .../SchemaSearch.php | 6 +- app/{SearchTypes => Criteria}/TagSearch.php | 6 +- app/{SearchTypes => Criteria}/UserSearch.php | 6 +- .../WhereBelongsToManyById.php | 6 +- .../WhereBelongsToSet.php | 6 +- .../WhereCreatedAfter.php | 6 +- .../WhereCreatedBefore.php | 6 +- .../WhereHasSlug.php | 6 +- app/Criteria/WhereHasStatusHidden.php | 22 + app/{SearchTypes => Criteria}/WhereInIds.php | 6 +- .../WhereSoldOut.php | 6 +- app/Dtos/AttributeDto.php | 73 ++ app/Dtos/AttributeOptionDto.php | 67 + app/Dtos/MetadataDto.php | 44 + app/Dtos/ProductCreateDto.php | 113 ++ app/Dtos/ProductSearchDto.php | 74 ++ app/Dtos/ProductSetDto.php | 7 + app/Dtos/ProductUpdateDto.php | 91 ++ app/Dtos/RoleSearchDto.php | 22 + app/Dtos/SeoMetadataDto.php | 6 +- app/Enums/AttributeType.php | 13 + app/Enums/MetadataType.php | 21 + app/Http/Controllers/AppController.php | 8 +- app/Http/Controllers/AttributeController.php | 65 + .../Controllers/AttributeOptionController.php | 51 + app/Http/Controllers/DiscountController.php | 4 +- app/Http/Controllers/FilterController.php | 25 + app/Http/Controllers/ItemController.php | 4 +- app/Http/Controllers/MetadataController.php | 48 + app/Http/Controllers/OrderController.php | 4 +- .../Controllers/PackageTemplateController.php | 8 +- app/Http/Controllers/PageController.php | 5 +- app/Http/Controllers/ProductController.php | 129 +- app/Http/Controllers/SchemaController.php | 2 +- .../Controllers/SeoMetadataController.php | 4 +- .../Controllers/ShippingMethodController.php | 1 + app/Http/Controllers/StatusController.php | 10 +- app/Http/Controllers/TagController.php | 2 +- app/Http/Controllers/UserController.php | 2 +- app/Http/Requests/AppIndexRequest.php | 16 + app/Http/Requests/AttributeIndexRequest.php | 20 + app/Http/Requests/AttributeOptionRequest.php | 28 + app/Http/Requests/AttributeStoreRequest.php | 35 + app/Http/Requests/AttributeUpdateRequest.php | 32 + .../Contracts/MetadataRequestContract.php | 8 + .../Requests/Contracts/SeoRequestContract.php | 8 + app/Http/Requests/DiscountIndexRequest.php | 2 + app/Http/Requests/FilterIndexRequest.php | 15 + app/Http/Requests/IndexSchemaRequest.php | 2 + app/Http/Requests/ItemIndexRequest.php | 2 + app/Http/Requests/OrderIndexRequest.php | 2 + .../Requests/PackageTemplateIndexRequest.php | 16 + app/Http/Requests/PageIndexRequest.php | 16 + app/Http/Requests/PageStoreRequest.php | 23 +- app/Http/Requests/PageUpdateRequest.php | 28 +- app/Http/Requests/ProductCreateRequest.php | 73 +- app/Http/Requests/ProductIndexRequest.php | 13 +- app/Http/Requests/ProductSetIndexRequest.php | 2 + app/Http/Requests/ProductSetStoreRequest.php | 45 +- app/Http/Requests/ProductSetUpdateRequest.php | 45 +- app/Http/Requests/ProductUpdateRequest.php | 7 +- app/Http/Requests/RoleIndexRequest.php | 2 + app/Http/Requests/SeoMetadataRequest.php | 22 - app/Http/Requests/SeoMetadataRulesRequest.php | 16 - app/Http/Requests/SeoRequest.php | 17 + .../Requests/ShippingMethodIndexRequest.php | 2 + app/Http/Requests/StatusIndexRequest.php | 16 + app/Http/Requests/UserIndexRequest.php | 2 + app/Http/Resources/AppResource.php | 7 +- .../Resources/AttributeOptionResource.php | 20 + app/Http/Resources/AttributeResource.php | 31 + app/Http/Resources/DiscountResource.php | 7 +- app/Http/Resources/ItemResource.php | 7 +- app/Http/Resources/MediaResource.php | 7 +- app/Http/Resources/MetadataResource.php | 19 + app/Http/Resources/OptionResource.php | 7 +- app/Http/Resources/OrderResource.php | 9 +- .../Resources/PackageTemplateResource.php | 7 +- app/Http/Resources/PageResource.php | 7 +- .../Resources/ProductAttributeResource.php | 23 + .../ProductAttributeShortResource.php | 16 + app/Http/Resources/ProductResource.php | 15 +- .../Resources/ProductSetChildrenResource.php | 8 +- .../ProductSetParentChildrenResource.php | 9 +- .../Resources/ProductSetParentResource.php | 12 +- app/Http/Resources/ProductSetResource.php | 11 +- .../Resources/ProductSetResourceUniversal.php | 2 +- app/Http/Resources/RoleResource.php | 7 +- app/Http/Resources/SchemaResource.php | 7 +- app/Http/Resources/ShippingMethodResource.php | 7 +- app/Http/Resources/StatusResource.php | 7 +- app/Http/Resources/UserResource.php | 7 +- app/Models/App.php | 13 +- app/Models/Attribute.php | 59 + app/Models/AttributeOption.php | 39 + app/Models/Discount.php | 16 +- app/Models/Item.php | 21 +- app/Models/Media.php | 3 +- app/Models/Metadata.php | 41 + app/Models/Option.php | 3 +- app/Models/Order.php | 23 +- app/Models/PackageTemplate.php | 11 +- app/Models/Page.php | 13 +- app/Models/Permission.php | 8 +- app/Models/Product.php | 89 +- app/Models/ProductAttribute.php | 36 + app/Models/ProductSet.php | 36 +- app/Models/Role.php | 17 +- app/Models/Schema.php | 17 +- app/Models/ShippingMethod.php | 11 +- app/Models/Status.php | 11 +- app/Models/Tag.php | 10 +- app/Models/User.php | 22 +- app/Models/WebHook.php | 10 +- app/Observers/AttributeOptionObserver.php | 61 + app/Observers/ProductObserver.php | 21 - app/Providers/AppServiceProvider.php | 24 +- app/Providers/EventServiceProvider.php | 7 +- app/Providers/RepositoryServiceProvider.php | 21 + .../Contracts/ProductRepositoryContract.php | 11 + app/Repositories/ProductRepository.php | 94 ++ app/Rules/AttributeOptionExist.php | 26 + app/Rules/CanShowPrivateMetadata.php | 32 + app/Rules/ProductAttributeOptions.php | 36 + app/SearchTypes/ProductSearch.php | 19 - app/SearchTypes/WhereHasStatusHidden.php | 22 - app/Services/AttributeOptionService.php | 48 + app/Services/AttributeService.php | 83 ++ app/Services/AvailabilityService.php | 35 +- .../AttributeOptionServiceContract.php | 17 + .../Contracts/AttributeServiceContract.php | 20 + .../Contracts/AvailabilityServiceContract.php | 2 + .../Contracts/MetadataServiceContract.php | 12 + .../Contracts/PageServiceContract.php | 2 +- .../ProductSearchServiceContract.php | 10 + .../Contracts/ProductServiceContract.php | 15 +- .../Contracts/ProductSetServiceContract.php | 2 + .../ShippingMethodServiceContract.php | 2 +- .../Contracts/SortServiceContract.php | 13 + app/Services/MediaService.php | 34 +- app/Services/MetadataService.php | 54 + app/Services/OrderService.php | 4 +- app/Services/PageService.php | 6 +- app/Services/PermissionService.php | 2 +- app/Services/ProductSearchService.php | 118 ++ app/Services/ProductService.php | 132 +- app/Services/ProductSetService.php | 29 +- app/Services/RoleService.php | 2 +- app/Services/ShippingMethodService.php | 7 +- app/Services/SortService.php | 44 + app/Services/UserService.php | 3 +- app/Services/WebHookService.php | 2 +- app/Traits/HasMetadata.php | 23 + app/Traits/MetadataResource.php | 32 + app/Traits/MetadataRules.php | 14 + app/Traits/PermissionUtility.php | 34 + app/Traits/SeoMetadataRules.php | 21 - app/Traits/SeoRules.php | 21 + app/Traits/Sortable.php | 40 + composer.json | 15 +- composer.lock | 414 +++++- config/app.php | 9 +- config/explorer.php | 31 + config/insights.php | 6 + config/pagination.php | 39 + config/scout.php | 101 ++ config/search.php | 84 ++ database/factories/AttributeFactory.php | 37 + database/factories/AttributeOptionFactory.php | 30 + database/factories/MetadataFactory.php | 30 + database/factories/RoleFactory.php | 2 +- ...094412_precalculate_product_visibility.php | 15 +- ...2_02_09_133640_create_attributes_table.php | 41 + ..._133727_create_attribute_options_table.php | 37 + ...022_02_10_135355_attribute_permissions.php | 71 + .../2022_02_15_113642_product_attribute.php | 48 + ...707_create_attribute_product_set_table.php | 31 + ...column_to_options_schemas_and_products.php | 6 +- ...022_03_03_095726_create_metadata_table.php | 36 + ..._03_03_103126_add_metadata_permissions.php | 48 + ...7_160126_add_more_metadata_permissions.php | 77 ++ ...oduct_attribute_attribute_option_table.php | 56 + database/seeders/ProductSeeder.php | 22 +- docker-compose.yaml | 34 +- docker/Dockerfile-elastic | 3 + docker/nginx/default.conf.template | 13 + heseya/pagination/.gitignore | 2 - heseya/pagination/composer.json | 28 - heseya/pagination/config/pagination.php | 9 - .../src/Http/Middleware/Pagination.php | 34 - heseya/pagination/src/ServiceProvider.php | 14 - .../pagination/tests/Unit/MiddlewareTest.php | 78 -- heseya/sortable/.gitignore | 2 - heseya/sortable/composer.json | 15 - heseya/sortable/src/Sortable.php | 57 - phpunit.xml | 3 - public/docs/api.yml | 90 ++ public/docs/paths/Attributes.yml | 164 +++ public/docs/paths/Filters.yml | 14 + public/docs/paths/Metadata.yml | 17 + public/docs/requests/Metadata.yml | 9 + public/docs/requests/ProductSets.yml | 12 + public/docs/requests/Products.yml | 10 + public/docs/schemas/Attributes.yml | 118 ++ public/docs/schemas/Metadata.yml | 12 + public/docs/schemas/Products.yml | 10 + routes/app.php | 5 + routes/attribute.php | 24 + routes/discount.php | 5 + routes/filters.php | 6 + routes/item.php | 5 + routes/media.php | 5 + routes/option.php | 11 + routes/order.php | 5 + routes/package-template.php | 5 + routes/page.php | 5 + routes/product-set.php | 5 + routes/product.php | 5 + routes/role.php | 5 + routes/schema.php | 5 + routes/shipping-method.php | 5 + routes/status.php | 5 + routes/user.php | 5 + tests/CreatesApplication.php | 1 + tests/Feature/AppInstallTest.php | 2 + tests/Feature/AppOtherTest.php | 1 + tests/Feature/AttributeTest.php | 1159 +++++++++++++++++ tests/Feature/DiscountTest.php | 3 + tests/Feature/FilterTest.php | 104 ++ tests/Feature/ItemTest.php | 13 +- tests/Feature/MediaTest.php | 1 + tests/Feature/MetadataFilterTest.php | 545 ++++++++ tests/Feature/MetadataFormsTest.php | 91 ++ tests/Feature/MetadataTest.php | 452 +++++++ tests/Feature/OrderTest.php | 35 + tests/Feature/OrderUpdateTest.php | 7 + tests/Feature/PackageTemplateTest.php | 1 + tests/Feature/PageTest.php | 48 +- tests/Feature/ProductSearchTest.php | 445 ++++++- tests/Feature/ProductSetCreateTest.php | 61 + tests/Feature/ProductSetShowTest.php | 55 + tests/Feature/ProductSetUpdateTest.php | 181 +++ tests/Feature/ProductTest.php | 578 +++++++- tests/Feature/RoleTest.php | 24 + tests/Feature/ShippingMethodTest.php | 1 + tests/Feature/StatusTest.php | 1 + tests/Feature/UserTest.php | 49 + tests/Support/ElasticTest.php | 34 + tests/Support/fake-response.json | 18 + tests/TestCase.php | 14 +- tests/Unit/ProductServiceTest.php | 4 +- 262 files changed, 8666 insertions(+), 1083 deletions(-) create mode 100644 app/Casts/MetadataValue.php rename app/{SearchTypes => Criteria}/DiscountSearch.php (81%) rename app/{SearchTypes => Criteria}/ItemSearch.php (76%) create mode 100644 app/Criteria/MetadataPrivateSearch.php create mode 100644 app/Criteria/MetadataSearch.php rename app/{SearchTypes => Criteria}/OrderSearch.php (96%) rename app/{SearchTypes => Criteria}/PermissionSearch.php (82%) rename app/{SearchTypes => Criteria}/ProductSetSearch.php (89%) rename app/{SearchTypes => Criteria}/RoleAssignableSearch.php (90%) rename app/{SearchTypes => Criteria}/RoleSearch.php (76%) rename app/{SearchTypes => Criteria}/SchemaSearch.php (76%) rename app/{SearchTypes => Criteria}/TagSearch.php (76%) rename app/{SearchTypes => Criteria}/UserSearch.php (76%) rename app/{SearchTypes => Criteria}/WhereBelongsToManyById.php (78%) rename app/{SearchTypes => Criteria}/WhereBelongsToSet.php (77%) rename app/{SearchTypes => Criteria}/WhereCreatedAfter.php (62%) rename app/{SearchTypes => Criteria}/WhereCreatedBefore.php (75%) rename app/{SearchTypes => Criteria}/WhereHasSlug.php (78%) create mode 100644 app/Criteria/WhereHasStatusHidden.php rename app/{SearchTypes => Criteria}/WhereInIds.php (64%) rename app/{SearchTypes => Criteria}/WhereSoldOut.php (64%) create mode 100644 app/Dtos/AttributeDto.php create mode 100644 app/Dtos/AttributeOptionDto.php create mode 100644 app/Dtos/MetadataDto.php create mode 100644 app/Dtos/ProductCreateDto.php create mode 100644 app/Dtos/ProductSearchDto.php create mode 100644 app/Dtos/ProductUpdateDto.php create mode 100644 app/Enums/AttributeType.php create mode 100644 app/Enums/MetadataType.php create mode 100644 app/Http/Controllers/AttributeController.php create mode 100644 app/Http/Controllers/AttributeOptionController.php create mode 100644 app/Http/Controllers/FilterController.php create mode 100644 app/Http/Controllers/MetadataController.php create mode 100644 app/Http/Requests/AppIndexRequest.php create mode 100644 app/Http/Requests/AttributeIndexRequest.php create mode 100644 app/Http/Requests/AttributeOptionRequest.php create mode 100644 app/Http/Requests/AttributeStoreRequest.php create mode 100644 app/Http/Requests/AttributeUpdateRequest.php create mode 100644 app/Http/Requests/Contracts/MetadataRequestContract.php create mode 100644 app/Http/Requests/Contracts/SeoRequestContract.php create mode 100644 app/Http/Requests/FilterIndexRequest.php create mode 100644 app/Http/Requests/PackageTemplateIndexRequest.php create mode 100644 app/Http/Requests/PageIndexRequest.php delete mode 100644 app/Http/Requests/SeoMetadataRequest.php delete mode 100644 app/Http/Requests/SeoMetadataRulesRequest.php create mode 100644 app/Http/Requests/SeoRequest.php create mode 100644 app/Http/Requests/StatusIndexRequest.php create mode 100644 app/Http/Resources/AttributeOptionResource.php create mode 100644 app/Http/Resources/AttributeResource.php create mode 100644 app/Http/Resources/MetadataResource.php create mode 100644 app/Http/Resources/ProductAttributeResource.php create mode 100644 app/Http/Resources/ProductAttributeShortResource.php create mode 100644 app/Models/Attribute.php create mode 100644 app/Models/AttributeOption.php create mode 100644 app/Models/Metadata.php create mode 100644 app/Models/ProductAttribute.php create mode 100644 app/Observers/AttributeOptionObserver.php delete mode 100644 app/Observers/ProductObserver.php create mode 100644 app/Providers/RepositoryServiceProvider.php create mode 100644 app/Repositories/Contracts/ProductRepositoryContract.php create mode 100644 app/Repositories/ProductRepository.php create mode 100644 app/Rules/AttributeOptionExist.php create mode 100644 app/Rules/CanShowPrivateMetadata.php create mode 100644 app/Rules/ProductAttributeOptions.php delete mode 100644 app/SearchTypes/ProductSearch.php delete mode 100644 app/SearchTypes/WhereHasStatusHidden.php create mode 100644 app/Services/AttributeOptionService.php create mode 100644 app/Services/AttributeService.php create mode 100644 app/Services/Contracts/AttributeOptionServiceContract.php create mode 100644 app/Services/Contracts/AttributeServiceContract.php create mode 100644 app/Services/Contracts/MetadataServiceContract.php create mode 100644 app/Services/Contracts/ProductSearchServiceContract.php create mode 100644 app/Services/Contracts/SortServiceContract.php create mode 100644 app/Services/MetadataService.php create mode 100644 app/Services/ProductSearchService.php create mode 100644 app/Services/SortService.php create mode 100644 app/Traits/HasMetadata.php create mode 100644 app/Traits/MetadataResource.php create mode 100644 app/Traits/MetadataRules.php create mode 100644 app/Traits/PermissionUtility.php delete mode 100644 app/Traits/SeoMetadataRules.php create mode 100644 app/Traits/SeoRules.php create mode 100644 app/Traits/Sortable.php create mode 100644 config/explorer.php create mode 100644 config/pagination.php create mode 100644 config/scout.php create mode 100644 config/search.php create mode 100644 database/factories/AttributeFactory.php create mode 100644 database/factories/AttributeOptionFactory.php create mode 100644 database/factories/MetadataFactory.php create mode 100644 database/migrations/2022_02_09_133640_create_attributes_table.php create mode 100644 database/migrations/2022_02_09_133727_create_attribute_options_table.php create mode 100644 database/migrations/2022_02_10_135355_attribute_permissions.php create mode 100644 database/migrations/2022_02_15_113642_product_attribute.php create mode 100644 database/migrations/2022_02_22_090707_create_attribute_product_set_table.php create mode 100644 database/migrations/2022_03_03_095726_create_metadata_table.php create mode 100644 database/migrations/2022_03_03_103126_add_metadata_permissions.php create mode 100644 database/migrations/2022_03_17_160126_add_more_metadata_permissions.php create mode 100644 database/migrations/2022_03_24_092349_create_product_attribute_attribute_option_table.php create mode 100644 docker/Dockerfile-elastic delete mode 100644 heseya/pagination/.gitignore delete mode 100644 heseya/pagination/composer.json delete mode 100644 heseya/pagination/config/pagination.php delete mode 100644 heseya/pagination/src/Http/Middleware/Pagination.php delete mode 100644 heseya/pagination/src/ServiceProvider.php delete mode 100644 heseya/pagination/tests/Unit/MiddlewareTest.php delete mode 100644 heseya/sortable/.gitignore delete mode 100644 heseya/sortable/composer.json delete mode 100644 heseya/sortable/src/Sortable.php create mode 100644 public/docs/paths/Attributes.yml create mode 100644 public/docs/paths/Filters.yml create mode 100644 public/docs/paths/Metadata.yml create mode 100644 public/docs/requests/Metadata.yml create mode 100644 public/docs/schemas/Attributes.yml create mode 100644 public/docs/schemas/Metadata.yml create mode 100644 routes/attribute.php create mode 100644 routes/filters.php create mode 100644 routes/option.php create mode 100644 tests/Feature/AttributeTest.php create mode 100644 tests/Feature/FilterTest.php create mode 100644 tests/Feature/MetadataFilterTest.php create mode 100644 tests/Feature/MetadataFormsTest.php create mode 100644 tests/Feature/MetadataTest.php create mode 100644 tests/Support/ElasticTest.php create mode 100644 tests/Support/fake-response.json diff --git a/.env.example b/.env.example index 2c7e32e24..294672dd0 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,6 @@ APP_STORE_URL=http://localhost APP_ADMIN_URL=http://admin.localhost LOG_CHANNEL=stack -L5_SWAGGER_GENERATE_ALWAYS=false QUEUE_CONNECTION=redis CACHE_DRIVER=redis @@ -41,9 +40,6 @@ SILVERBOX_HOST=silverbox.localhost SILVERBOX_CLIENT=heseya SILVERBOX_KEY=secret -FACEBOOK_APP_ID= -FACEBOOK_APP_SECRET= - FURGONETKA_ENABLED=false FURGONETKA_WEBHOOK_SALT=tajnasól FURGONETKA_LOGIN= diff --git a/app/Casts/MetadataValue.php b/app/Casts/MetadataValue.php new file mode 100644 index 000000000..416859692 --- /dev/null +++ b/app/Casts/MetadataValue.php @@ -0,0 +1,44 @@ +value_type->value) { + MetadataType::BOOLEAN => (bool) $value, + MetadataType::NUMBER => (float) $value, + default => $value + }; + } + + /** + * Prepare the given value for storage. + * + * @param Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * + * @return mixed + */ + public function set($model, string $key, $value, array $attributes) + { + return $value; + } +} diff --git a/app/SearchTypes/DiscountSearch.php b/app/Criteria/DiscountSearch.php similarity index 81% rename from app/SearchTypes/DiscountSearch.php rename to app/Criteria/DiscountSearch.php index eef7b9964..f76ef80ed 100644 --- a/app/SearchTypes/DiscountSearch.php +++ b/app/Criteria/DiscountSearch.php @@ -1,11 +1,11 @@ deniesAbilityByModel('show_metadata_private', $query->getModel())) { + return $query; + } + + return $query->where(function (Builder $query): void { + $query->whereHas('metadataPrivate', function (Builder $query): void { + $first = true; + foreach ($this->value as $key => $value) { + if ($first) { + $query->where('name', 'LIKE', "%${key}%") + ->where('value', 'LIKE', "%${value}%"); + $first = false; + } else { + $query->orWhere('name', 'LIKE', "%${key}%") + ->where('value', 'LIKE', "%${value}%"); + } + } + }); + }); + } +} diff --git a/app/Criteria/MetadataSearch.php b/app/Criteria/MetadataSearch.php new file mode 100644 index 000000000..356f9405c --- /dev/null +++ b/app/Criteria/MetadataSearch.php @@ -0,0 +1,26 @@ +value as $key => $value) { + $query->whereHas($relation, function (Builder $query) use ($key, $value): void { + $query->where('name', '=', $key) + ->where('value', '=', $value); + }); + } + + return $query; + } + + public function query(Builder $query): Builder + { + return $this->makeQuery($query, 'metadata'); + } +} diff --git a/app/SearchTypes/OrderSearch.php b/app/Criteria/OrderSearch.php similarity index 96% rename from app/SearchTypes/OrderSearch.php rename to app/Criteria/OrderSearch.php index f24fadf2a..dc6b8e6a2 100644 --- a/app/SearchTypes/OrderSearch.php +++ b/app/Criteria/OrderSearch.php @@ -1,11 +1,11 @@ where(function (Builder $query): void { + $query->whereHas('status', function (Builder $query) { + return $query->where('hidden', $this->value); + }); + + if (!$this->value) { + $query->orWhereDoesntHave('status'); + } + }); + } +} diff --git a/app/SearchTypes/WhereInIds.php b/app/Criteria/WhereInIds.php similarity index 64% rename from app/SearchTypes/WhereInIds.php rename to app/Criteria/WhereInIds.php index 7717aed02..aee963274 100644 --- a/app/SearchTypes/WhereInIds.php +++ b/app/Criteria/WhereInIds.php @@ -1,11 +1,11 @@ input('name'), + slug: $request->input('slug'), + description: $request->input('description', new Missing()), + type: $request->input('type'), + global: $request->input('global'), + sortable: $request->input('sortable'), + options: $request->has('options') ? array_map( + fn ($data) => AttributeOptionDto::fromDataArray($data), + $request->input('options') + ) : [], + ); + } + + public function getName(): string + { + return $this->name; + } + + public function getSlug(): string + { + return $this->slug; + } + + public function getDescription(): string|null|Missing + { + return $this->description; + } + + public function getType(): string + { + return $this->type; + } + + public function isGlobal(): bool + { + return $this->global; + } + + public function isSortable(): bool + { + return $this->sortable; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } +} diff --git a/app/Dtos/AttributeOptionDto.php b/app/Dtos/AttributeOptionDto.php new file mode 100644 index 000000000..2af58d3d2 --- /dev/null +++ b/app/Dtos/AttributeOptionDto.php @@ -0,0 +1,67 @@ +input('id', new Missing()), + name: $request->input('name', new Missing()), + value_number: $request->input('value_number', new Missing()), + value_date: $request->input('value_date', new Missing()), + ); + } + + public static function fromDataArray(array $data): self + { + return new self( + id: array_key_exists('id', $data) ? $data['id'] : null, + name: array_key_exists('name', $data) ? $data['name'] : null, + value_number: array_key_exists('value_number', $data) ? $data['value_number'] : null, + value_date: array_key_exists('value_date', $data) ? $data['value_date'] : null, + ); + } + + /** + * @return string|Missing|null + */ + public function getId(): string|null|Missing + { + return $this->id; + } + + /** + * @return string|Missing|null + */ + public function getName(): string|null|Missing + { + return $this->name; + } + + /** + * @return float|Missing|null + */ + public function getValueNumber(): float|null|Missing + { + return $this->value_number; + } + + /** + * @return string|Missing|null + */ + public function getValueDate(): string|null|Missing + { + return $this->value_date; + } +} diff --git a/app/Dtos/MetadataDto.php b/app/Dtos/MetadataDto.php new file mode 100644 index 000000000..c2524a12c --- /dev/null +++ b/app/Dtos/MetadataDto.php @@ -0,0 +1,44 @@ +name; + } + + public function getValue(): bool|int|float|string|null + { + return $this->value; + } + + public function isPublic(): bool + { + return $this->public; + } + + public function getValueType(): string + { + return $this->value_type; + } +} diff --git a/app/Dtos/ProductCreateDto.php b/app/Dtos/ProductCreateDto.php new file mode 100644 index 000000000..fcae5edb6 --- /dev/null +++ b/app/Dtos/ProductCreateDto.php @@ -0,0 +1,113 @@ +input('name'), + slug: $request->input('slug'), + price: $request->input('price'), + public: $request->boolean('public'), + order: $request->input('order', new Missing()), + quantity_step: $request->input('quantity_step', new Missing()), + description_html: $request->input('description_html'), + description_short: $request->input('description_short'), + media: $request->input('media', new Missing()), + tags: $request->input('tags', new Missing()), + schemas: $request->input('schemas', new Missing()), + sets: $request->input('sets', new Missing()), + items: $request->input('items', new Missing()), + seo: SeoMetadataDto::fromFormRequest($request), + metadata: self::mapMetadata($request), + attributes: $request->input('attributes', new Missing()), + ); + } + + public static function mapMetadata(Request $request): array|Missing + { + $metadata = Collection::make(); + + if ($request->has('metadata')) { + foreach ($request->input('metadata') as $key => $value) { + $metadata->push(MetadataDto::manualInit($key, $value, true)); + } + } + + if ($request->has('metadata_private')) { + foreach ($request->input('metadata_private') as $key => $value) { + $metadata->push(MetadataDto::manualInit($key, $value, false)); + } + } + + return $metadata->isEmpty() ? new Missing() : $metadata->toArray(); + } + + public function getMedia(): Missing|array + { + return $this->media; + } + + public function getTags(): Missing|array + { + return $this->tags; + } + + public function getSchemas(): Missing|array + { + return $this->schemas; + } + + public function getSets(): Missing|array + { + return $this->sets; + } + + public function getItems(): Missing|array + { + return $this->items; + } + + public function getSeo(): SeoMetadataDto + { + return $this->seo; + } + + public function getMetadata(): Missing|array + { + return $this->metadata; + } + + public function getAttributes(): Missing|array + { + return $this->attributes; + } +} diff --git a/app/Dtos/ProductSearchDto.php b/app/Dtos/ProductSearchDto.php new file mode 100644 index 000000000..90ce59b12 --- /dev/null +++ b/app/Dtos/ProductSearchDto.php @@ -0,0 +1,74 @@ +input('search'), + sort: $request->input('sort'), + ids: $request->input('ids', new Missing()), + slug: $request->input('slug', new Missing()), + name: $request->input('name', new Missing()), + public: self::boolean('public', $request), + available: self::boolean('available', $request), + sets: self::array('sets', $request), + tags: self::array('tags', $request), + metadata: self::array('metadata', $request), + metadata_private: self::array('metadata_private', $request), + ); + } + + public function getSearch(): ?string + { + return $this->search; + } + + public function getSort(): ?string + { + return $this->sort; + } + + private static function boolean(string $key, ProductIndexRequest $request): bool|Missing + { + if (!$request->has($key)) { + return new Missing(); + } + + return $request->boolean($key); + } + + private static function array(string $key, ProductIndexRequest $request): array|Missing + { + if (!$request->has($key) || $request->input($key) === null) { + return new Missing(); + } + + if (!is_array($request->input($key))) { + return [$request->input($key)]; + } + + return $request->input($key); + } +} diff --git a/app/Dtos/ProductSetDto.php b/app/Dtos/ProductSetDto.php index 2709d5b07..f7dbb6fc7 100644 --- a/app/Dtos/ProductSetDto.php +++ b/app/Dtos/ProductSetDto.php @@ -19,6 +19,7 @@ class ProductSetDto extends Dto private SeoMetadataDto $seo; private string|null|Missing $description_html; private string|null|Missing $cover_id; + private array|null|Missing $attributes_ids; public static function fromFormRequest(ProductSetStoreRequest|ProductSetUpdateRequest $request): self { @@ -33,6 +34,7 @@ public static function fromFormRequest(ProductSetStoreRequest|ProductSetUpdateRe seo: SeoMetadataDto::fromFormRequest($request), description_html: $request->input('description_html'), cover_id: $request->input('cover_id'), + attributes_ids: $request->input('attributes'), ); } @@ -85,4 +87,9 @@ public function getCoverId(): Missing|string|null { return $this->cover_id; } + + public function getAttributesIds(): Missing|null|array + { + return $this->attributes_ids; + } } diff --git a/app/Dtos/ProductUpdateDto.php b/app/Dtos/ProductUpdateDto.php new file mode 100644 index 000000000..24a8dc9b3 --- /dev/null +++ b/app/Dtos/ProductUpdateDto.php @@ -0,0 +1,91 @@ +input('name', new Missing()), + slug: $request->input('slug', new Missing()), + price: $request->input('price', new Missing()), + public: $request->boolean('public', new Missing()), + order: $request->input('order', new Missing()), + quantity_step: $request->input('quantity_step', new Missing()), + description_html: $request->input('description_html', new Missing()), + description_short: $request->input('description_short', new Missing()), + media: $request->input('media', new Missing()), + tags: $request->input('tags', new Missing()), + schemas: $request->input('schemas', new Missing()), + sets: $request->input('sets', new Missing()), + items: $request->input('items', new Missing()), + seo: SeoMetadataDto::fromFormRequest($request), + attributes: $request->input('attributes', new Missing()), + ); + } + + public function getMedia(): Missing|array + { + return $this->media; + } + + public function getTags(): Missing|array + { + return $this->tags; + } + + public function getSchemas(): Missing|array + { + return $this->schemas; + } + + public function getSets(): Missing|array + { + return $this->sets; + } + + public function getItems(): Missing|array + { + return $this->items; + } + + public function getSeo(): SeoMetadataDto + { + return $this->seo; + } + + public function getMetadata(): Missing + { + return new Missing(); + } + + public function getAttributes(): Missing|array + { + return $this->attributes; + } +} diff --git a/app/Dtos/RoleSearchDto.php b/app/Dtos/RoleSearchDto.php index c428e6528..5e1331f4c 100644 --- a/app/Dtos/RoleSearchDto.php +++ b/app/Dtos/RoleSearchDto.php @@ -13,6 +13,8 @@ public function __construct( private ?string $name, private ?string $description, private ?bool $assignable, + private ?array $metadata, + private ?array $metadata_private, ) { } @@ -36,6 +38,14 @@ public function toArray(): array $data['assignable'] = $this->getAssignable(); } + if ($this->getMetadata() !== null) { + $data['metadata'] = $this->getMetadata(); + } + + if ($this->getMetadataPrivate() !== null) { + $data['metadata_private'] = $this->getMetadataPrivate(); + } + return $data; } @@ -46,6 +56,8 @@ public static function instantiateFromRequest(Request $request): self $request->input('name', null), $request->input('description', null), $request->has('assignable') ? $request->boolean('assignable') : null, + $request->input('metadata', null), + $request->input('metadata_private', null), ); } @@ -68,4 +80,14 @@ public function getAssignable(): ?bool { return $this->assignable; } + + public function getMetadata(): ?array + { + return $this->metadata; + } + + public function getMetadataPrivate(): ?array + { + return $this->metadata_private; + } } diff --git a/app/Dtos/SeoMetadataDto.php b/app/Dtos/SeoMetadataDto.php index e80e454c6..09fc78ef5 100644 --- a/app/Dtos/SeoMetadataDto.php +++ b/app/Dtos/SeoMetadataDto.php @@ -3,8 +3,7 @@ namespace App\Dtos; use App\Enums\TwitterCardType; -use App\Http\Requests\SeoMetadataRequest; -use App\Http\Requests\SeoMetadataRulesRequest; +use App\Http\Requests\Contracts\SeoRequestContract; use Heseya\Dto\Dto; use Heseya\Dto\Missing; @@ -19,9 +18,10 @@ class SeoMetadataDto extends Dto private string|null|Missing $model_type; private bool|Missing $no_index; - public static function fromFormRequest(SeoMetadataRulesRequest|SeoMetadataRequest $request): self + public static function fromFormRequest(SeoRequestContract $request): self { $seo = $request->has('seo') ? 'seo.' : ''; + return new self( title: $request->input($seo . 'title', new Missing()), description: $request->input($seo . 'description', new Missing()), diff --git a/app/Enums/AttributeType.php b/app/Enums/AttributeType.php new file mode 100644 index 000000000..d08a3cf12 --- /dev/null +++ b/app/Enums/AttributeType.php @@ -0,0 +1,13 @@ + self::BOOLEAN, + 'integer', 'double' => self::NUMBER, + default => self::STRING, + }; + } +} diff --git a/app/Http/Controllers/AppController.php b/app/Http/Controllers/AppController.php index 108a3bcd8..0a3c5ba71 100644 --- a/app/Http/Controllers/AppController.php +++ b/app/Http/Controllers/AppController.php @@ -4,6 +4,7 @@ use App\Dtos\AppInstallDto; use App\Http\Requests\AppDeleteRequest; +use App\Http\Requests\AppIndexRequest; use App\Http\Requests\AppStoreRequest; use App\Http\Resources\AppResource; use App\Models\App; @@ -19,9 +20,12 @@ public function __construct(private AppServiceContract $appService) { } - public function index(): JsonResource + public function index(AppIndexRequest $request): JsonResource { - return AppResource::collection(App::paginate(Config::get('pagination.per_page'))); + $apps = App::searchByCriteria($request->validated()) + ->with('metadata'); + + return AppResource::collection($apps->paginate(Config::get('pagination.per_page'))); } public function show(App $app): JsonResource diff --git a/app/Http/Controllers/AttributeController.php b/app/Http/Controllers/AttributeController.php new file mode 100644 index 000000000..ef860eb79 --- /dev/null +++ b/app/Http/Controllers/AttributeController.php @@ -0,0 +1,65 @@ +validated()) + ->with('options'); + + return AttributeResource::collection( + $query->paginate(Config::get('pagination.per_page')) + ); + } + + public function show(Attribute $attribute): JsonResource + { + $attribute->load('options'); + + return AttributeResource::make($attribute); + } + + public function store(AttributeStoreRequest $request): JsonResource + { + $attribute = $this->attributeService->create( + AttributeDto::fromFormRequest($request) + ); + + return AttributeResource::make($attribute); + } + + public function update(Attribute $attribute, AttributeUpdateRequest $request): JsonResource + { + $attribute = $this->attributeService->update( + $attribute, + AttributeDto::fromFormRequest($request) + ); + + return AttributeResource::make($attribute); + } + + public function destroy(Attribute $attribute): JsonResponse + { + $this->attributeService->delete($attribute); + + return Response::json(null, JsonResponse::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/AttributeOptionController.php b/app/Http/Controllers/AttributeOptionController.php new file mode 100644 index 000000000..a2a2ed516 --- /dev/null +++ b/app/Http/Controllers/AttributeOptionController.php @@ -0,0 +1,51 @@ +attributeOptionService->create( + $attribute->getKey(), + AttributeOptionDto::fromFormRequest($request) + ); + + return AttributeOptionResource::make($attributeOption); + } + + public function update(Attribute $attribute, AttributeOption $option, AttributeOptionRequest $request): JsonResource + { + if (!$request->has('id')) { + $request->merge(['id' => $option->getKey()]); + } + + $attributeOption = $this->attributeOptionService->updateOrCreate( + $attribute->getKey(), + AttributeOptionDto::fromFormRequest($request) + ); + + return AttributeOptionResource::make($attributeOption); + } + + public function destroy(Attribute $attribute, AttributeOption $option): JsonResponse + { + $this->attributeOptionService->delete($option); + + return Response::json(null, JsonResponse::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/DiscountController.php b/app/Http/Controllers/DiscountController.php index db737544d..8280806e2 100644 --- a/app/Http/Controllers/DiscountController.php +++ b/app/Http/Controllers/DiscountController.php @@ -19,9 +19,9 @@ class DiscountController extends Controller { public function index(DiscountIndexRequest $request): JsonResource { - $query = Discount::search($request->validated()) + $query = Discount::searchByCriteria($request->validated()) ->orderBy('updated_at', 'DESC') - ->with('orders'); + ->with(['orders', 'metadata']); return DiscountResource::collection( $query->paginate(Config::get('pagination.per_page')), diff --git a/app/Http/Controllers/FilterController.php b/app/Http/Controllers/FilterController.php new file mode 100644 index 000000000..6641c4e6c --- /dev/null +++ b/app/Http/Controllers/FilterController.php @@ -0,0 +1,25 @@ +has('sets')) { + return AttributeResource::collection(Attribute::where('global', true)->get()); + } + + return AttributeResource::collection( + Attribute::whereHas( + 'productSets', + fn ($query) => $query->whereIn('product_set_id', $request->input('sets')), + )->orWhere('global', true)->with('options')->get() + ); + } +} diff --git a/app/Http/Controllers/ItemController.php b/app/Http/Controllers/ItemController.php index ff92e8248..94dbc1668 100644 --- a/app/Http/Controllers/ItemController.php +++ b/app/Http/Controllers/ItemController.php @@ -19,9 +19,9 @@ class ItemController extends Controller { public function index(ItemIndexRequest $request): JsonResource { - $items = Item::search($request->validated()) + $items = Item::searchByCriteria($request->validated()) ->sort($request->input('sort', 'sku')) - ->with('deposits'); + ->with(['deposits', 'metadata']); return ItemResource::collection( $items->paginate(Config::get('pagination.per_page')), diff --git a/app/Http/Controllers/MetadataController.php b/app/Http/Controllers/MetadataController.php new file mode 100644 index 000000000..0afacb0c8 --- /dev/null +++ b/app/Http/Controllers/MetadataController.php @@ -0,0 +1,48 @@ +metadataService->returnModel($request->segments()); + + if ($model === null) { + return Response::json(null, JsonResponse::HTTP_UNPROCESSABLE_ENTITY); + } + + $model = $model->findOrFail($modelId); + $public = Collection::make($request->segments())->last() === 'metadata'; + + foreach ($request->all() as $key => $value) { + $dto = MetadataDto::manualInit(name: $key, value: $value, public: $public); + + $this->metadataService->updateOrCreate( + $model, + $dto + ); + } + + $model->refresh(); + + if ($public) { + return MetadataResource::make($model->metadata); + } + + return MetadataResource::make($model->metadataPrivate); + } +} diff --git a/app/Http/Controllers/OrderController.php b/app/Http/Controllers/OrderController.php index 9ca90fef7..2ff8733cc 100644 --- a/app/Http/Controllers/OrderController.php +++ b/app/Http/Controllers/OrderController.php @@ -51,9 +51,9 @@ public function index(OrderIndexRequest $request): JsonResource $search_data = !$request->has('status_id') ? $request->validated() + ['status.hidden' => 0] : $request->validated(); - $query = Order::search($search_data) + $query = Order::searchByCriteria($search_data) ->sort($request->input('sort')) - ->with(['products', 'discounts', 'payments']); + ->with(['products', 'discounts', 'payments', 'metadata']); return OrderResource::collection( $query->paginate(Config::get('pagination.per_page')), diff --git a/app/Http/Controllers/PackageTemplateController.php b/app/Http/Controllers/PackageTemplateController.php index fb1e0ed50..214b445b5 100644 --- a/app/Http/Controllers/PackageTemplateController.php +++ b/app/Http/Controllers/PackageTemplateController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Http\Requests\PackageTemplateIndexRequest; use App\Http\Resources\PackageTemplateResource; use App\Models\PackageTemplate; use Illuminate\Http\JsonResponse; @@ -11,11 +12,12 @@ class PackageTemplateController extends Controller { - public function index(): JsonResource + public function index(PackageTemplateIndexRequest $request): JsonResource { - $packages = PackageTemplate::all(); + $packages = PackageTemplate::searchByCriteria($request->validated()) + ->with(['metadata']); - return PackageTemplateResource::collection($packages); + return PackageTemplateResource::collection($packages->get()); } public function store(Request $request): JsonResource diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index a01c4d045..5f325e475 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Dtos\PageDto; +use App\Http\Requests\PageIndexRequest; use App\Http\Requests\PageReorderRequest; use App\Http\Requests\PageStoreRequest; use App\Http\Requests\PageUpdateRequest; @@ -22,10 +23,10 @@ public function __construct(PageServiceContract $pageService) $this->pageService = $pageService; } - public function index(): JsonResource + public function index(PageIndexRequest $request): JsonResource { return PageResource::collection( - $this->pageService->getPaginated(), + $this->pageService->getPaginated($request->validated()), ); } diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index db2a06fee..2b4a8de11 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -2,26 +2,18 @@ namespace App\Http\Controllers; -use App\Dtos\SeoMetadataDto; -use App\Events\ProductCreated; -use App\Events\ProductDeleted; -use App\Events\ProductUpdated; +use App\Dtos\ProductCreateDto; +use App\Dtos\ProductSearchDto; +use App\Dtos\ProductUpdateDto; use App\Http\Requests\ProductCreateRequest; use App\Http\Requests\ProductIndexRequest; -use App\Http\Requests\ProductShowRequest; use App\Http\Requests\ProductUpdateRequest; use App\Http\Resources\ProductResource; use App\Models\Product; -use App\Models\ProductSet; -use App\Services\Contracts\MediaServiceContract; +use App\Repositories\Contracts\ProductRepositoryContract; use App\Services\Contracts\ProductServiceContract; -use App\Services\Contracts\ProductSetServiceContract; -use App\Services\Contracts\SchemaServiceContract; -use App\Services\Contracts\SeoMetadataServiceContract; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -29,68 +21,21 @@ class ProductController extends Controller { public function __construct( - private MediaServiceContract $mediaService, - private SchemaServiceContract $schemaService, private ProductServiceContract $productService, - private ProductSetServiceContract $productSetService, - private SeoMetadataServiceContract $seoMetadataService + private ProductRepositoryContract $productRepository, ) { } public function index(ProductIndexRequest $request): JsonResource { - $query = Product::search($request->validated()); - - $query - ->sort($request->input('sort', 'order')) - ->with(['media', 'tags', 'schemas', 'sets', 'seo', 'items']); - - if (Gate::denies('products.show_hidden')) { - $query->public(); - } - - if ($request->has('sets')) { - $setsFound = ProductSet::whereIn( - 'slug', - $request->input('sets') ?? [], - )->with('allChildren')->get(); - - $canShowHiddenSets = Gate::allows('product_sets.show_hidden'); - $relationScope = $canShowHiddenSets ? 'allChildren' : 'allChildrenPublic'; - - $setsFlat = $this->productSetService - ->flattenSetsTree($setsFound, $relationScope) - ->map(fn (ProductSet $set) => $set->slug); - - $query->whereHas('sets', function (Builder $query) use ($setsFlat) { - return $query->whereIn( - 'slug', - $setsFlat, - ); - }); - } - - if ( - Gate::denies('products.show_hidden') && - !$request->hasAny(['sets', 'search', 'name', 'slug', 'public', 'tags']) - ) { - $query->whereDoesntHave('sets', function (Builder $query) { - return $query->where('hide_on_index', true); - }); - } - - $products = $query->paginate(Config::get('pagination.per_page')); - - if ($request->has('available')) { - $products = $products->filter(function ($product) use ($request) { - return $product->available === $request->boolean('available'); - }); - } + $products = $this->productRepository->search( + ProductSearchDto::instantiateFromRequest($request), + ); return ProductResource::collection($products)->full($request->has('full')); } - public function show(ProductShowRequest $request, Product $product): JsonResource + public function show(Product $product): JsonResource { if (Gate::denies('products.show_hidden') && !$product->public) { throw new NotFoundHttpException(); @@ -101,66 +46,26 @@ public function show(ProductShowRequest $request, Product $product): JsonResourc public function store(ProductCreateRequest $request): JsonResource { - $product = Product::create($request->validated()); - - $this->productService->assignItems($product, $request->items); - - $this->productSetup($product, $request); - - $seo_dto = SeoMetadataDto::fromFormRequest($request); - $product->seo()->save($this->seoMetadataService->create($seo_dto)); - - ProductCreated::dispatch($product); + $product = $this->productService->create( + ProductCreateDto::instantiateFromRequest($request), + ); return ProductResource::make($product); } - public function productSetup( - Product $product, - ProductCreateRequest|ProductUpdateRequest $request, - ): void { - $this->mediaService->sync($product, $request->input('media', [])); - $product->tags()->sync($request->input('tags', [])); - - if ($request->has('schemas')) { - $this->schemaService->sync($product, $request->input('schemas')); - } - - $this->productService->updateMinMaxPrices($product); - - if ($request->has('sets')) { - $product->sets()->sync($request->input('sets')); - } - } - public function update(ProductUpdateRequest $request, Product $product): JsonResource { - $product->update($request->validated()); - - $this->productService->assignItems($product, $request->items); - - $this->productSetup($product, $request); - - if ($request->has('seo')) { - $seo_dto = SeoMetadataDto::fromFormRequest($request); - $this->seoMetadataService->update($seo_dto, $product->seo); - } - - ProductUpdated::dispatch($product); + $product = $this->productService->update( + $product, + ProductUpdateDto::instantiateFromRequest($request), + ); return ProductResource::make($product); } public function destroy(Product $product): JsonResponse { - $this->mediaService->sync($product); - - if ($product->delete()) { - ProductDeleted::dispatch($product); - if ($product->seo !== null) { - $this->seoMetadataService->delete($product->seo); - } - } + $this->productService->delete($product); return Response::json(null, 204); } diff --git a/app/Http/Controllers/SchemaController.php b/app/Http/Controllers/SchemaController.php index 634fe1304..1251ab184 100644 --- a/app/Http/Controllers/SchemaController.php +++ b/app/Http/Controllers/SchemaController.php @@ -24,7 +24,7 @@ public function __construct( public function index(IndexSchemaRequest $request): JsonResource { - $schemas = Schema::search($request->validated())->sort($request->input('sort')); + $schemas = Schema::searchByCriteria($request->validated())->sort($request->input('sort')); return SchemaResource::collection( $schemas->paginate(Config::get('pagination.per_page')), diff --git a/app/Http/Controllers/SeoMetadataController.php b/app/Http/Controllers/SeoMetadataController.php index d2299695a..4debb827f 100644 --- a/app/Http/Controllers/SeoMetadataController.php +++ b/app/Http/Controllers/SeoMetadataController.php @@ -5,7 +5,7 @@ use App\Dtos\SeoKeywordsDto; use App\Dtos\SeoMetadataDto; use App\Http\Requests\SeoKeywordsRequest; -use App\Http\Requests\SeoMetadataRequest; +use App\Http\Requests\SeoRequest; use App\Http\Resources\SeoKeywordsResource; use App\Http\Resources\SeoMetadataResource; use App\Models\SeoMetadata; @@ -27,7 +27,7 @@ public function show(SeoMetadata $seoMetadata): JsonResource return SeoMetadataResource::make($this->seoMetadataService->show()); } - public function createOrUpdate(SeoMetadataRequest $request): JsonResource + public function createOrUpdate(SeoRequest $request): JsonResource { $seo = SeoMetadataDto::fromFormRequest($request); return SeoMetadataResource::make($this->seoMetadataService->createOrUpdate($seo)); diff --git a/app/Http/Controllers/ShippingMethodController.php b/app/Http/Controllers/ShippingMethodController.php index cfe2767db..2a67baa57 100644 --- a/app/Http/Controllers/ShippingMethodController.php +++ b/app/Http/Controllers/ShippingMethodController.php @@ -24,6 +24,7 @@ public function __construct( public function index(ShippingMethodIndexRequest $request): JsonResource { $shippingMethods = $this->shippingMethodService->index( + $request->only('metadata', 'metadata_private'), $request->input('country'), $request->input('cart_value', 0), ); diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php index e46c9ca50..6a4026d61 100644 --- a/app/Http/Controllers/StatusController.php +++ b/app/Http/Controllers/StatusController.php @@ -4,6 +4,7 @@ use App\Exceptions\Error; use App\Http\Requests\StatusCreateRequest; +use App\Http\Requests\StatusIndexRequest; use App\Http\Requests\StatusReorderRequest; use App\Http\Requests\StatusUpdateRequest; use App\Http\Resources\StatusResource; @@ -14,9 +15,14 @@ class StatusController extends Controller { - public function index(): JsonResource + public function index(StatusIndexRequest $request): JsonResource { - return StatusResource::collection(Status::orderBy('order')->get()); + $statuses = Status::searchByCriteria($request->validated()) + ->with(['metadata']); + + return StatusResource::collection( + $statuses->orderBy('order')->get() + ); } public function store(StatusCreateRequest $request): JsonResource diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php index bdcf2eaa0..96bf7ec1e 100644 --- a/app/Http/Controllers/TagController.php +++ b/app/Http/Controllers/TagController.php @@ -15,7 +15,7 @@ class TagController extends Controller { public function index(TagIndexRequest $request): JsonResource { - $tags = Tag::search($request->validated()) + $tags = Tag::searchByCriteria($request->validated()) ->withCount('products') ->orderBy('products_count', 'DESC') ->paginate(Config::get('pagination.per_page')); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index c2467ed27..a29c1b786 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -24,7 +24,7 @@ public function __construct(UserServiceContract $userService) public function index(UserIndexRequest $request): JsonResource { $paginator = $this->userService->index( - $request->only('name', 'email', 'search', 'ids'), + $request->only('name', 'email', 'search', 'ids', 'metadata', 'metadata_private'), $request->input('sort', 'created_at:asc'), $request->input('pagination_limit', 12) ); diff --git a/app/Http/Requests/AppIndexRequest.php b/app/Http/Requests/AppIndexRequest.php new file mode 100644 index 000000000..e95660bfe --- /dev/null +++ b/app/Http/Requests/AppIndexRequest.php @@ -0,0 +1,16 @@ + ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/AttributeIndexRequest.php b/app/Http/Requests/AttributeIndexRequest.php new file mode 100644 index 000000000..f8055e4ee --- /dev/null +++ b/app/Http/Requests/AttributeIndexRequest.php @@ -0,0 +1,20 @@ + ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/AttributeOptionRequest.php b/app/Http/Requests/AttributeOptionRequest.php new file mode 100644 index 000000000..22122ecf3 --- /dev/null +++ b/app/Http/Requests/AttributeOptionRequest.php @@ -0,0 +1,28 @@ +attribute?->type->value ?? $this->input('type')) { + AttributeType::SINGLE_OPTION => 'required', + default => 'nullable' + }; + + return [ + 'name' => [$nameRule, 'string', 'max:255'], + 'value_number' => ['nullable', 'numeric', 'regex:/^\d{1,6}(\.\d{1,2}|)$/'], + 'value_date' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/AttributeStoreRequest.php b/app/Http/Requests/AttributeStoreRequest.php new file mode 100644 index 000000000..5b5672a79 --- /dev/null +++ b/app/Http/Requests/AttributeStoreRequest.php @@ -0,0 +1,35 @@ +all()); + $optionRules = []; + + foreach ($attributeOptionRequest->rules() as $field => $rules) { + $optionRules['options.*.' . $field] = $rules; + } + + return array_merge([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'unique:attributes'], + 'description' => ['nullable', 'string', 'max:255'], + 'type' => ['required', new EnumValue(AttributeType::class, false)], + 'global' => ['required', 'boolean'], + 'sortable' => ['required', 'boolean'], + 'options' => ['nullable', 'array'], + ], $optionRules); + } +} diff --git a/app/Http/Requests/AttributeUpdateRequest.php b/app/Http/Requests/AttributeUpdateRequest.php new file mode 100644 index 000000000..1f50192f1 --- /dev/null +++ b/app/Http/Requests/AttributeUpdateRequest.php @@ -0,0 +1,32 @@ +ignore($this->attribute, 'slug'), + ]; + + $rules['type'] = [ + 'required', + Rule::in($this->attribute->type), + ]; + + return $rules; + } +} diff --git a/app/Http/Requests/Contracts/MetadataRequestContract.php b/app/Http/Requests/Contracts/MetadataRequestContract.php new file mode 100644 index 000000000..39724be4b --- /dev/null +++ b/app/Http/Requests/Contracts/MetadataRequestContract.php @@ -0,0 +1,8 @@ + ['string', 'max:255'], 'description' => ['string', 'max:255'], 'code' => ['string', 'max:64'], + 'metadata' => ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], ]; } } diff --git a/app/Http/Requests/FilterIndexRequest.php b/app/Http/Requests/FilterIndexRequest.php new file mode 100644 index 000000000..02bbd0cbc --- /dev/null +++ b/app/Http/Requests/FilterIndexRequest.php @@ -0,0 +1,15 @@ + ['array'], + ]; + } +} diff --git a/app/Http/Requests/IndexSchemaRequest.php b/app/Http/Requests/IndexSchemaRequest.php index fd20a7092..efa6e001f 100644 --- a/app/Http/Requests/IndexSchemaRequest.php +++ b/app/Http/Requests/IndexSchemaRequest.php @@ -15,6 +15,8 @@ public function rules(): array 'search' => ['nullable', 'string', 'max:255'], 'sort' => ['nullable', 'string', 'max:255'], + 'metadata' => ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], ]; } } diff --git a/app/Http/Requests/ItemIndexRequest.php b/app/Http/Requests/ItemIndexRequest.php index 86ec570ca..d577f2b5f 100644 --- a/app/Http/Requests/ItemIndexRequest.php +++ b/app/Http/Requests/ItemIndexRequest.php @@ -17,6 +17,8 @@ public function rules(): array 'sort' => ['nullable', 'string', 'max:255'], 'sold_out' => ['nullable', 'boolean', 'prohibited_unless:day,null'], 'day' => ['nullable', 'date', 'before_or_equal:now'], + 'metadata' => ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], ]; } diff --git a/app/Http/Requests/OrderIndexRequest.php b/app/Http/Requests/OrderIndexRequest.php index c205737ba..698d02fc2 100644 --- a/app/Http/Requests/OrderIndexRequest.php +++ b/app/Http/Requests/OrderIndexRequest.php @@ -17,6 +17,8 @@ public function rules(): array 'paid' => ['nullable', 'boolean'], 'from' => ['nullable', 'date'], 'to' => ['nullable', 'date', 'after_or_equal:from'], + 'metadata' => ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], ]; } } diff --git a/app/Http/Requests/PackageTemplateIndexRequest.php b/app/Http/Requests/PackageTemplateIndexRequest.php new file mode 100644 index 000000000..2a2fdf790 --- /dev/null +++ b/app/Http/Requests/PackageTemplateIndexRequest.php @@ -0,0 +1,16 @@ + ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/PageIndexRequest.php b/app/Http/Requests/PageIndexRequest.php new file mode 100644 index 000000000..baebd80b0 --- /dev/null +++ b/app/Http/Requests/PageIndexRequest.php @@ -0,0 +1,16 @@ + ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/PageStoreRequest.php b/app/Http/Requests/PageStoreRequest.php index 79ed6576d..22a877218 100644 --- a/app/Http/Requests/PageStoreRequest.php +++ b/app/Http/Requests/PageStoreRequest.php @@ -2,15 +2,24 @@ namespace App\Http\Requests; -class PageStoreRequest extends SeoMetadataRulesRequest +use App\Http\Requests\Contracts\SeoRequestContract; +use App\Traits\SeoRules; +use Illuminate\Foundation\Http\FormRequest; + +class PageStoreRequest extends FormRequest implements SeoRequestContract { + use SeoRules; + public function rules(): array { - return $this->rulesWithSeo([ - 'name' => ['required', 'string', 'max:255'], - 'slug' => ['required', 'string', 'max:255'], - 'public' => ['boolean'], - 'content_html' => ['required', 'string', 'min:1'], - ]); + return array_merge( + $this->seoRules('seo.'), + [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255'], + 'public' => ['boolean'], + 'content_html' => ['required', 'string', 'min:1'], + ], + ); } } diff --git a/app/Http/Requests/PageUpdateRequest.php b/app/Http/Requests/PageUpdateRequest.php index b849c8bd6..b0e87c97a 100644 --- a/app/Http/Requests/PageUpdateRequest.php +++ b/app/Http/Requests/PageUpdateRequest.php @@ -2,21 +2,29 @@ namespace App\Http\Requests; +use App\Http\Requests\Contracts\SeoRequestContract; +use App\Traits\SeoRules; +use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -class PageUpdateRequest extends SeoMetadataRulesRequest +class PageUpdateRequest extends FormRequest implements SeoRequestContract { + use SeoRules; + public function rules(): array { - return $this->rulesWithSeo([ - 'name' => ['string', 'max:255'], - 'slug' => [ - 'string', - 'max:255', - Rule::unique('pages')->ignore($this->route('page')->slug, 'slug'), + return array_merge( + $this->seoRules(), + [ + 'name' => ['string', 'max:255'], + 'slug' => [ + 'string', + 'max:255', + Rule::unique('pages')->ignore($this->route('page')->slug, 'slug'), + ], + 'public' => ['boolean'], + 'content_html' => ['string', 'min:1'], ], - 'public' => ['boolean'], - 'content_html' => ['string', 'min:1'], - ]); + ); } } diff --git a/app/Http/Requests/ProductCreateRequest.php b/app/Http/Requests/ProductCreateRequest.php index e7b7a0a93..dd8822fbc 100644 --- a/app/Http/Requests/ProductCreateRequest.php +++ b/app/Http/Requests/ProductCreateRequest.php @@ -2,37 +2,56 @@ namespace App\Http\Requests; +use App\Http\Requests\Contracts\MetadataRequestContract; +use App\Http\Requests\Contracts\SeoRequestContract; +use App\Rules\AttributeOptionExist; +use App\Rules\ProductAttributeOptions; use App\Rules\UniqueIdInRequest; +use App\Traits\MetadataRules; +use App\Traits\SeoRules; +use Illuminate\Foundation\Http\FormRequest; -class ProductCreateRequest extends SeoMetadataRulesRequest +class ProductCreateRequest extends FormRequest implements SeoRequestContract, MetadataRequestContract { + use SeoRules, MetadataRules; + public function rules(): array { - return $this->rulesWithSeo([ - 'name' => ['required', 'string', 'max:255'], - 'slug' => ['required', 'string', 'max:255', 'unique:products', 'alpha_dash'], - 'price' => ['required', 'numeric', 'min:0'], - 'description_html' => ['nullable', 'string'], - 'description_short' => ['nullable', 'string', 'between:30,5000'], - 'public' => ['required', 'boolean'], - 'quantity_step' => ['numeric'], - 'order' => ['nullable', 'numeric'], - - 'media' => ['nullable', 'array'], - 'media.*' => ['uuid', 'exists:media,id'], - - 'tags' => ['nullable', 'array'], - 'tags.*' => ['uuid', 'exists:tags,id'], - - 'schemas' => ['nullable', 'array'], - 'schemas.*' => ['uuid', 'exists:schemas,id'], - - 'sets' => ['nullable', 'array'], - 'sets.*' => ['uuid', 'exists:product_sets,id'], - - 'items' => ['nullable', 'array', new UniqueIdInRequest()], - 'items.*.id' => ['uuid'], - 'items.*.quantity' => ['numeric'], - ]); + return array_merge( + $this->seoRules(), + $this->metadataRules(), + [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'unique:products', 'alpha_dash'], + 'price' => ['required', 'numeric', 'min:0'], + 'public' => ['required', 'boolean'], + + 'description_html' => ['nullable', 'string'], + 'description_short' => ['nullable', 'string', 'between:30,5000'], + + 'quantity_step' => ['numeric'], + 'order' => ['nullable', 'numeric'], + + 'media' => ['nullable', 'array'], + 'media.*' => ['uuid', 'exists:media,id'], + + 'tags' => ['nullable', 'array'], + 'tags.*' => ['uuid', 'exists:tags,id'], + + 'attributes' => ['nullable', 'array'], + 'attributes.*' => ['bail', 'array', new ProductAttributeOptions()], + 'attributes.*.*' => ['uuid', new AttributeOptionExist()], + + 'items' => ['nullable', 'array', new UniqueIdInRequest()], + 'items.*.id' => ['uuid'], + 'items.*.quantity' => ['numeric'], + + 'schemas' => ['nullable', 'array'], + 'schemas.*' => ['uuid', 'exists:schemas,id'], + + 'sets' => ['nullable', 'array'], + 'sets.*' => ['uuid', 'exists:product_sets,id'], + ], + ); } } diff --git a/app/Http/Requests/ProductIndexRequest.php b/app/Http/Requests/ProductIndexRequest.php index 30c8f5d57..184ab2cc8 100644 --- a/app/Http/Requests/ProductIndexRequest.php +++ b/app/Http/Requests/ProductIndexRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Rules\CanShowPrivateMetadata; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Gate; use Illuminate\Validation\Rule; @@ -19,6 +20,8 @@ public function rules(): array } return [ + 'search' => ['nullable', 'string', 'max:255'], + 'ids' => ['string'], 'name' => ['nullable', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255'], @@ -28,14 +31,14 @@ public function rules(): array 'string', $setsExist, ], - 'search' => ['nullable', 'string', 'max:255'], 'sort' => ['nullable', 'string', 'max:255'], 'available' => ['nullable'], 'tags' => ['nullable', 'array'], - 'tags.*' => [ - 'string', - 'uuid', - ], + 'tags.*' => ['string', 'uuid'], + 'metadata' => ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array', new CanShowPrivateMetadata()], + + 'full' => ['nullable', 'boolean'], ]; } } diff --git a/app/Http/Requests/ProductSetIndexRequest.php b/app/Http/Requests/ProductSetIndexRequest.php index 4cb6f2dca..c971ee9ed 100644 --- a/app/Http/Requests/ProductSetIndexRequest.php +++ b/app/Http/Requests/ProductSetIndexRequest.php @@ -15,6 +15,8 @@ public function rules(): array 'public' => ['nullable', 'boolean'], 'tree' => ['nullable', 'boolean'], 'root' => ['nullable', 'boolean'], + 'metadata' => ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], ]; } } diff --git a/app/Http/Requests/ProductSetStoreRequest.php b/app/Http/Requests/ProductSetStoreRequest.php index c83e75af0..1615efcc1 100644 --- a/app/Http/Requests/ProductSetStoreRequest.php +++ b/app/Http/Requests/ProductSetStoreRequest.php @@ -2,26 +2,37 @@ namespace App\Http\Requests; -class ProductSetStoreRequest extends SeoMetadataRulesRequest +use App\Http\Requests\Contracts\SeoRequestContract; +use App\Traits\SeoRules; +use Illuminate\Foundation\Http\FormRequest; + +class ProductSetStoreRequest extends FormRequest implements SeoRequestContract { + use SeoRules; + public function rules(): array { - return $this->rulesWithSeo([ - 'name' => ['required', 'string', 'max:255'], - 'slug_suffix' => [ - 'required', - 'string', - 'max:255', - 'alpha_dash', + return array_merge( + $this->seoRules(), + [ + 'name' => ['required', 'string', 'max:255'], + 'slug_suffix' => [ + 'required', + 'string', + 'max:255', + 'alpha_dash', + ], + 'slug_override' => ['required', 'boolean'], + 'public' => ['boolean'], + 'hide_on_index' => ['boolean'], + 'parent_id' => ['uuid', 'nullable', 'exists:product_sets,id'], + 'children_ids' => ['array'], + 'children_ids.*' => ['uuid', 'exists:product_sets,id'], + 'description_html' => ['nullable', 'string'], + 'cover_id' => ['uuid', 'uuid', 'exists:media,id'], + 'attributes' => ['array'], + 'attributes.*' => ['uuid', 'exists:attributes,id'], ], - 'slug_override' => ['required', 'boolean'], - 'public' => ['boolean'], - 'hide_on_index' => ['boolean'], - 'parent_id' => ['uuid', 'nullable', 'exists:product_sets,id'], - 'children_ids' => ['array'], - 'children_ids.*' => ['uuid', 'exists:product_sets,id'], - 'description_html' => ['nullable', 'string'], - 'cover_id' => ['uuid', 'uuid', 'exists:media,id'], - ]); + ); } } diff --git a/app/Http/Requests/ProductSetUpdateRequest.php b/app/Http/Requests/ProductSetUpdateRequest.php index 7d9a13bbb..c80ee692c 100644 --- a/app/Http/Requests/ProductSetUpdateRequest.php +++ b/app/Http/Requests/ProductSetUpdateRequest.php @@ -2,26 +2,37 @@ namespace App\Http\Requests; -class ProductSetUpdateRequest extends SeoMetadataRulesRequest +use App\Http\Requests\Contracts\SeoRequestContract; +use App\Traits\SeoRules; +use Illuminate\Foundation\Http\FormRequest; + +class ProductSetUpdateRequest extends FormRequest implements SeoRequestContract { + use SeoRules; + public function rules(): array { - return $this->rulesWithSeo([ - 'name' => ['required', 'string', 'max:255'], - 'slug_suffix' => [ - 'required', - 'string', - 'max:255', - 'alpha_dash', + return array_merge( + $this->seoRules(), + [ + 'name' => ['required', 'string', 'max:255'], + 'slug_suffix' => [ + 'required', + 'string', + 'max:255', + 'alpha_dash', + ], + 'slug_override' => ['required', 'boolean'], + 'public' => ['required', 'boolean'], + 'hide_on_index' => ['required', 'boolean'], + 'parent_id' => ['present', 'nullable', 'uuid', 'exists:product_sets,id'], + 'children_ids' => ['present', 'array'], + 'children_ids.*' => ['uuid', 'exists:product_sets,id'], + 'description_html' => ['nullable', 'string'], + 'cover_id' => ['uuid', 'uuid', 'exists:media,id'], + 'attributes' => ['array'], + 'attributes.*' => ['uuid', 'exists:attributes,id'], ], - 'slug_override' => ['required', 'boolean'], - 'public' => ['required', 'boolean'], - 'hide_on_index' => ['required', 'boolean'], - 'parent_id' => ['present', 'nullable', 'uuid', 'exists:product_sets,id'], - 'children_ids' => ['present', 'array'], - 'children_ids.*' => ['uuid', 'exists:product_sets,id'], - 'description_html' => ['nullable', 'string'], - 'cover_id' => ['uuid', 'uuid', 'exists:media,id'], - ]); + ); } } diff --git a/app/Http/Requests/ProductUpdateRequest.php b/app/Http/Requests/ProductUpdateRequest.php index 237a222d7..078302df7 100644 --- a/app/Http/Requests/ProductUpdateRequest.php +++ b/app/Http/Requests/ProductUpdateRequest.php @@ -10,8 +10,13 @@ public function rules(): array { $rules = parent::rules(); + // TODO: should be uncommented in future +// $rules['metadata'] = ['prohibited']; +// $rules['metadata_private'] = ['prohibited']; + $rules['name'] = ['string', 'max:255']; + $rules['price'] = ['numeric', 'min:0']; + $rules['public'] = ['boolean']; $rules['slug'] = [ - 'required', 'string', 'max:255', 'alpha_dash', diff --git a/app/Http/Requests/RoleIndexRequest.php b/app/Http/Requests/RoleIndexRequest.php index 9e63bb273..7d5d6fe38 100644 --- a/app/Http/Requests/RoleIndexRequest.php +++ b/app/Http/Requests/RoleIndexRequest.php @@ -13,6 +13,8 @@ public function rules(): array 'name' => ['string'], 'description' => ['string'], 'assignable' => ['boolean'], + 'metadata' => ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], ]; } } diff --git a/app/Http/Requests/SeoMetadataRequest.php b/app/Http/Requests/SeoMetadataRequest.php deleted file mode 100644 index f00e5f20f..000000000 --- a/app/Http/Requests/SeoMetadataRequest.php +++ /dev/null @@ -1,22 +0,0 @@ - ['nullable', 'string', 'max:255'], - 'description' => ['nullable', 'string', 'max:1000'], - 'keywords' => ['nullable', 'array'], - 'og_image_id' => ['nullable', 'uuid', 'exists:media,id'], - 'twitter_card' => ['nullable', new EnumValue(TwitterCardType::class, false)], - 'no_index' => ['nullable', 'boolean'], - ]; - } -} diff --git a/app/Http/Requests/SeoMetadataRulesRequest.php b/app/Http/Requests/SeoMetadataRulesRequest.php deleted file mode 100644 index ec11df0e5..000000000 --- a/app/Http/Requests/SeoMetadataRulesRequest.php +++ /dev/null @@ -1,16 +0,0 @@ -seoRules()); - } -} diff --git a/app/Http/Requests/SeoRequest.php b/app/Http/Requests/SeoRequest.php new file mode 100644 index 000000000..0a7884bc6 --- /dev/null +++ b/app/Http/Requests/SeoRequest.php @@ -0,0 +1,17 @@ +seoRules(''); + } +} diff --git a/app/Http/Requests/ShippingMethodIndexRequest.php b/app/Http/Requests/ShippingMethodIndexRequest.php index 827aa592c..dbf861a91 100644 --- a/app/Http/Requests/ShippingMethodIndexRequest.php +++ b/app/Http/Requests/ShippingMethodIndexRequest.php @@ -11,6 +11,8 @@ public function rules(): array return [ 'country' => ['string', 'size:2', 'exists:countries,code'], 'cart_value' => ['numeric'], + 'metadata' => ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], ]; } } diff --git a/app/Http/Requests/StatusIndexRequest.php b/app/Http/Requests/StatusIndexRequest.php new file mode 100644 index 000000000..5195a8c17 --- /dev/null +++ b/app/Http/Requests/StatusIndexRequest.php @@ -0,0 +1,16 @@ + ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/UserIndexRequest.php b/app/Http/Requests/UserIndexRequest.php index afa563aa2..027ed32e8 100644 --- a/app/Http/Requests/UserIndexRequest.php +++ b/app/Http/Requests/UserIndexRequest.php @@ -14,6 +14,8 @@ public function rules(): array 'email' => ['nullable', 'string'], 'sort' => ['nullable', 'string'], 'pagination_limit' => ['nullable', 'integer', 'min:1'], + 'metadata' => ['nullable', 'array'], + 'metadata_private' => ['nullable', 'array'], ]; } } diff --git a/app/Http/Resources/AppResource.php b/app/Http/Resources/AppResource.php index fe3ac1c51..714acecc6 100644 --- a/app/Http/Resources/AppResource.php +++ b/app/Http/Resources/AppResource.php @@ -2,13 +2,16 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; class AppResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'url' => $this->resource->url, 'microfrontend_url' => $this->resource->microfrontend_url, @@ -18,7 +21,7 @@ public function base(Request $request): array 'description' => $this->resource->description, 'icon' => $this->resource->icon, 'author' => $this->resource->author, - ]; + ], $this->metadataResource('apps.show_metadata_private')); } public function view(Request $request): array diff --git a/app/Http/Resources/AttributeOptionResource.php b/app/Http/Resources/AttributeOptionResource.php new file mode 100644 index 000000000..f75db3720 --- /dev/null +++ b/app/Http/Resources/AttributeOptionResource.php @@ -0,0 +1,20 @@ + $this->resource->getKey(), + 'name' => $this->resource->name, + 'index' => $this->resource->index, + 'value_number' => $this->resource->value_number, + 'value_date' => $this->resource->value_date, + 'attribute_id' => $this->resource->attribute_id, + ]; + } +} diff --git a/app/Http/Resources/AttributeResource.php b/app/Http/Resources/AttributeResource.php new file mode 100644 index 000000000..afcd8d02d --- /dev/null +++ b/app/Http/Resources/AttributeResource.php @@ -0,0 +1,31 @@ +resource->type->value) { + AttributeType::NUMBER => [$this->resource->min_number, $this->resource->max_number], + AttributeType::DATE => [$this->resource->min_date, $this->resource->max_date], + default => [null, null], + }; + + return [ + 'id' => $this->resource->getKey(), + 'name' => $this->resource->name, + 'slug' => $this->resource->slug, + 'description' => $this->resource->description, + 'min' => $min, + 'max' => $max, + 'type' => $this->resource->type, + 'global' => $this->resource->global, + 'sortable' => $this->resource->sortable, + 'options' => AttributeOptionResource::collection($this->resource->options), + ]; + } +} diff --git a/app/Http/Resources/DiscountResource.php b/app/Http/Resources/DiscountResource.php index d83433e76..adfc11ff0 100644 --- a/app/Http/Resources/DiscountResource.php +++ b/app/Http/Resources/DiscountResource.php @@ -2,10 +2,13 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; class DiscountResource extends Resource { + use MetadataResource; + public function base(Request $request): array { if (isset($this->resource->pivot)) { @@ -13,7 +16,7 @@ public function base(Request $request): array $this->resource->discount = $this->resource->pivot->discount; } - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'code' => $this->resource->code, 'description' => $this->resource->description, @@ -24,6 +27,6 @@ public function base(Request $request): array 'available' => $this->resource->available, 'starts_at' => $this->resource->starts_at, 'expires_at' => $this->resource->expires_at, - ]; + ], $this->metadataResource('discounts.show_metadata_private')); } } diff --git a/app/Http/Resources/ItemResource.php b/app/Http/Resources/ItemResource.php index c5f9a2781..ff74da010 100644 --- a/app/Http/Resources/ItemResource.php +++ b/app/Http/Resources/ItemResource.php @@ -2,17 +2,20 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; class ItemResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'name' => $this->resource->name, 'sku' => $this->resource->sku, 'quantity' => $this->resource->getQuantity($request->input('day')), - ]; + ], $this->metadataResource('items.show_metadata_private')); } } diff --git a/app/Http/Resources/MediaResource.php b/app/Http/Resources/MediaResource.php index 9338e04bd..ae2823ec8 100644 --- a/app/Http/Resources/MediaResource.php +++ b/app/Http/Resources/MediaResource.php @@ -2,19 +2,22 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; use Illuminate\Support\Str; class MediaResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'type' => Str::lower($this->resource->type->key), 'url' => $this->resource->url, 'slug' => $this->resource->slug, 'alt' => $this->resource->alt, - ]; + ], $this->metadataResource('media.show_metadata_private')); } } diff --git a/app/Http/Resources/MetadataResource.php b/app/Http/Resources/MetadataResource.php new file mode 100644 index 000000000..866b6983a --- /dev/null +++ b/app/Http/Resources/MetadataResource.php @@ -0,0 +1,19 @@ +resource->map(function ($metadata) use (&$resource): void { + $resource[$metadata->name] = $metadata->value; + }); + + return $resource; + } +} diff --git a/app/Http/Resources/OptionResource.php b/app/Http/Resources/OptionResource.php index 617158444..88722e745 100644 --- a/app/Http/Resources/OptionResource.php +++ b/app/Http/Resources/OptionResource.php @@ -2,19 +2,22 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; class OptionResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'name' => $this->resource->name, 'price' => $this->resource->price, 'disabled' => $this->resource->disabled, 'available' => $this->resource->available, 'items' => ItemPublicResource::collection($this->resource->items), - ]; + ], $this->metadataResource('options.show_metadata_private')); } } diff --git a/app/Http/Resources/OrderResource.php b/app/Http/Resources/OrderResource.php index 4d7f33e9f..3b7973076 100644 --- a/app/Http/Resources/OrderResource.php +++ b/app/Http/Resources/OrderResource.php @@ -3,17 +3,20 @@ namespace App\Http\Resources; use App\Models\User; +use App\Traits\MetadataResource; use Illuminate\Http\Request; class OrderResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'code' => $this->resource->code, - 'email' => $this->resource->email, 'currency' => $this->resource->currency, + 'email' => $this->resource->email, 'summary' => $this->resource->summary, 'summary_paid' => $this->resource->paid_amount, 'shipping_price' => $this->resource->shipping_price, @@ -25,7 +28,7 @@ public function base(Request $request): array AddressResource::make($this->resource->deliveryAddress) : null, 'shipping_method' => $this->resource->shippingMethod ? ShippingMethodResource::make($this->resource->shippingMethod) : null, - ]; + ], $this->metadataResource('orders.show_metadata_private')); } public function view(Request $request): array diff --git a/app/Http/Resources/PackageTemplateResource.php b/app/Http/Resources/PackageTemplateResource.php index 2a22df344..2a68ce800 100644 --- a/app/Http/Resources/PackageTemplateResource.php +++ b/app/Http/Resources/PackageTemplateResource.php @@ -2,19 +2,22 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; class PackageTemplateResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'name' => $this->resource->name, 'weight' => $this->resource->weight, 'width' => $this->resource->width, 'height' => $this->resource->height, 'depth' => $this->resource->depth, - ]; + ], $this->metadataResource('packages.show_metadata_private')); } } diff --git a/app/Http/Resources/PageResource.php b/app/Http/Resources/PageResource.php index 528ec868f..647e71e53 100644 --- a/app/Http/Resources/PageResource.php +++ b/app/Http/Resources/PageResource.php @@ -2,19 +2,22 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; class PageResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'slug' => $this->resource->slug, 'name' => $this->resource->name, 'public' => $this->resource->public, 'order' => $this->resource->order, - ]; + ], $this->metadataResource('pages.show_metadata_private')); } public function view(Request $request): array diff --git a/app/Http/Resources/ProductAttributeResource.php b/app/Http/Resources/ProductAttributeResource.php new file mode 100644 index 000000000..759cdf110 --- /dev/null +++ b/app/Http/Resources/ProductAttributeResource.php @@ -0,0 +1,23 @@ + $this->resource->getKey(), + 'slug' => $this->resource->slug, + 'description' => $this->resource->description, + 'type' => $this->resource->type->value, + 'global' => $this->resource->global, + 'sortable' => $this->resource->sortable, + ] + ); + } +} diff --git a/app/Http/Resources/ProductAttributeShortResource.php b/app/Http/Resources/ProductAttributeShortResource.php new file mode 100644 index 000000000..077af796d --- /dev/null +++ b/app/Http/Resources/ProductAttributeShortResource.php @@ -0,0 +1,16 @@ + $this->resource->name, + 'selected_options' => AttributeOptionResource::collection($this->resource->pivot->options), + ]; + } +} diff --git a/app/Http/Resources/ProductResource.php b/app/Http/Resources/ProductResource.php index 7c4ef879e..0e65707b6 100644 --- a/app/Http/Resources/ProductResource.php +++ b/app/Http/Resources/ProductResource.php @@ -2,14 +2,17 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; class ProductResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'slug' => $this->resource->slug, 'name' => $this->resource->name, @@ -23,7 +26,7 @@ public function base(Request $request): array 'cover' => MediaResource::make($this->resource->media->first()), 'tags' => TagResource::collection($this->resource->tags), 'items' => ProductItemResource::collection($this->resource->items), - ]; + ], $this->metadataResource('products.show_metadata_private')); } public function view(Request $request): array @@ -45,6 +48,14 @@ public function view(Request $request): array 'schemas' => SchemaResource::collection($this->resource->schemas), 'sets' => ProductSetResource::collection($sets), 'seo' => SeoMetadataResource::make($this->resource->seo), + 'attributes' => ProductAttributeResource::collection($this->resource->attributes), + ]; + } + + public function index(Request $request): array + { + return [ + 'attributes' => ProductAttributeShortResource::collection($this->resource->attributes), ]; } } diff --git a/app/Http/Resources/ProductSetChildrenResource.php b/app/Http/Resources/ProductSetChildrenResource.php index fc703ab64..616dc08fd 100644 --- a/app/Http/Resources/ProductSetChildrenResource.php +++ b/app/Http/Resources/ProductSetChildrenResource.php @@ -2,18 +2,21 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; class ProductSetChildrenResource extends Resource { + use MetadataResource; + public function base(Request $request): array { $children = Gate::denies('product_sets.show_hidden') ? $this->resource->childrenPublic : $this->resource->children; - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'name' => $this->resource->name, 'slug' => $this->resource->slug, @@ -25,6 +28,7 @@ public function base(Request $request): array 'parent_id' => $this->resource->parent_id, 'children' => ProductSetChildrenResource::collection($children), 'cover' => MediaResource::make($this->resource->media), - ]; + 'attributes' => AttributeResource::collection($this->resource->attributes), + ], $this->metadataResource('product_sets.show_metadata_private')); } } diff --git a/app/Http/Resources/ProductSetParentChildrenResource.php b/app/Http/Resources/ProductSetParentChildrenResource.php index 7057e4558..b3772162f 100644 --- a/app/Http/Resources/ProductSetParentChildrenResource.php +++ b/app/Http/Resources/ProductSetParentChildrenResource.php @@ -2,21 +2,24 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; class ProductSetParentChildrenResource extends Resource { + use MetadataResource; + public function base(Request $request): array { $children = Gate::denies('product_sets.show_hidden') ? $this->resource->childrenPublic : $this->resource->children; - return [ + return array_merge([ 'id' => $this->resource->getKey(), - 'name' => $this->resource->name, 'slug' => $this->resource->slug, + 'name' => $this->resource->name, 'slug_suffix' => $this->resource->slugSuffix, 'slug_override' => $this->resource->slugOverride, 'public' => $this->resource->public, @@ -27,6 +30,6 @@ public function base(Request $request): array 'seo' => SeoMetadataResource::make($this->resource->seo), 'description_html' => $this->resource->description_html, 'cover' => MediaResource::make($this->resource->media), - ]; + ], $this->metadataResource('product_sets.show_metadata_private')); } } diff --git a/app/Http/Resources/ProductSetParentResource.php b/app/Http/Resources/ProductSetParentResource.php index f61bfea48..7698aa62d 100644 --- a/app/Http/Resources/ProductSetParentResource.php +++ b/app/Http/Resources/ProductSetParentResource.php @@ -2,18 +2,21 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; class ProductSetParentResource extends Resource { + use MetadataResource; + public function base(Request $request): array { $children = Gate::denies('product_sets.show_hidden') ? $this->resource->childrenPublic : $this->resource->children; - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'name' => $this->resource->name, 'slug' => $this->resource->slug, @@ -23,12 +26,11 @@ public function base(Request $request): array 'visible' => $this->resource->public_parent && $this->resource->public, 'hide_on_index' => $this->resource->hide_on_index, 'parent' => ProductSetResource::make($this->resource->parent), - 'children_ids' => $children->map( - fn ($child) => $child->getKey(), - )->toArray(), + 'children_ids' => $children->map(fn ($child) => $child->getKey()), 'seo' => SeoMetadataResource::make($this->resource->seo), 'description_html' => $this->resource->description_html, 'cover' => MediaResource::make($this->resource->media), - ]; + 'attributes' => AttributeResource::collection($this->resource->attributes), + ], $this->metadataResource('product_sets.show_metadata_private')); } } diff --git a/app/Http/Resources/ProductSetResource.php b/app/Http/Resources/ProductSetResource.php index a3e6b4345..761d042fb 100644 --- a/app/Http/Resources/ProductSetResource.php +++ b/app/Http/Resources/ProductSetResource.php @@ -2,18 +2,21 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; class ProductSetResource extends Resource { + use MetadataResource; + public function base(Request $request): array { $children = Gate::denies('product_sets.show_hidden') ? $this->resource->childrenPublic : $this->resource->children; - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'name' => $this->resource->name, 'slug' => $this->resource->slug, @@ -23,10 +26,8 @@ public function base(Request $request): array 'visible' => $this->resource->public_parent && $this->resource->public, 'hide_on_index' => $this->resource->hide_on_index, 'parent_id' => $this->resource->parent_id, - 'children_ids' => $children->map( - fn ($child) => $child->getKey(), - )->toArray(), + 'children_ids' => $children->map(fn ($child) => $child->getKey())->toArray(), 'cover' => MediaResource::make($this->resource->media), - ]; + ], $this->metadataResource('product_sets.show_metadata_private')); } } diff --git a/app/Http/Resources/ProductSetResourceUniversal.php b/app/Http/Resources/ProductSetResourceUniversal.php index 7aa1aaaa4..3f9f83b36 100644 --- a/app/Http/Resources/ProductSetResourceUniversal.php +++ b/app/Http/Resources/ProductSetResourceUniversal.php @@ -45,7 +45,7 @@ public function base(Request $request): array $childrenResource = $this->showChildren ? [ 'children' => ProductSetResourceUniversal::collection($children)->setIsPublic($this->public), ] : [ - 'children_ids' => $children->map(fn ($child) => $child->getKey())->toArray(), + 'children_ids' => $children->map(fn ($child) => $child->getKey()), ]; return [ diff --git a/app/Http/Resources/RoleResource.php b/app/Http/Resources/RoleResource.php index cd87dee69..ffdc3ad9e 100644 --- a/app/Http/Resources/RoleResource.php +++ b/app/Http/Resources/RoleResource.php @@ -3,14 +3,17 @@ namespace App\Http\Resources; use App\Enums\RoleType; +use App\Traits\MetadataResource; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; class RoleResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'name' => $this->resource->name, 'description' => $this->resource->description, @@ -21,7 +24,7 @@ public function base(Request $request): array $this->resource->getAllPermissions(), ) : false, 'deletable' => $this->resource->type->is(RoleType::REGULAR), - ]; + ], $this->metadataResource('roles.show_metadata_private')); } public function view(Request $request): array diff --git a/app/Http/Resources/SchemaResource.php b/app/Http/Resources/SchemaResource.php index ed00b9567..e9b92420b 100644 --- a/app/Http/Resources/SchemaResource.php +++ b/app/Http/Resources/SchemaResource.php @@ -2,14 +2,17 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; use Illuminate\Support\Str; class SchemaResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'type' => Str::lower($this->resource->type->key), 'name' => $this->resource->name, @@ -26,7 +29,7 @@ public function base(Request $request): array 'validation' => $this->resource->validation, 'options' => OptionResource::collection($this->resource->options), 'used_schemas' => $this->resource->usedSchemas->map(fn ($schema) => $schema->getKey()), - ]; + ], $this->metadataResource('schemas.show_metadata_private')); } public function view(Request $request): array diff --git a/app/Http/Resources/ShippingMethodResource.php b/app/Http/Resources/ShippingMethodResource.php index c409ca36d..26f63af27 100644 --- a/app/Http/Resources/ShippingMethodResource.php +++ b/app/Http/Resources/ShippingMethodResource.php @@ -2,13 +2,16 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; class ShippingMethodResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'name' => $this->resource->name, 'price' => $this->resource->price, @@ -19,6 +22,6 @@ public function base(Request $request): array 'price_ranges' => PriceRangeResource::collection($this->resource->priceRanges->sortBy('start')), 'shipping_time_min' => $this->resource->shipping_time_min, 'shipping_time_max' => $this->resource->shipping_time_max, - ]; + ], $this->metadataResource('shipping_methods.show_metadata_private')); } } diff --git a/app/Http/Resources/StatusResource.php b/app/Http/Resources/StatusResource.php index 33429050a..6d7eb9eb3 100644 --- a/app/Http/Resources/StatusResource.php +++ b/app/Http/Resources/StatusResource.php @@ -2,13 +2,16 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; class StatusResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'name' => $this->resource->name, 'color' => $this->resource->color, @@ -16,6 +19,6 @@ public function base(Request $request): array 'description' => $this->resource->description, 'hidden' => $this->resource->hidden, 'no_notifications' => $this->resource->no_notifications, - ]; + ], $this->metadataResource('statuses.show_metadata_private')); } } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 310cbb8fe..a2018600f 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -2,20 +2,23 @@ namespace App\Http\Resources; +use App\Traits\MetadataResource; use Illuminate\Http\Request; class UserResource extends Resource { + use MetadataResource; + public function base(Request $request): array { - return [ + return array_merge([ 'id' => $this->resource->getKey(), 'email' => $this->resource->email, 'name' => $this->resource->name, 'avatar' => $this->resource->avatar, 'roles' => RoleResource::collection($this->resource->roles), 'is_tfa_active' => $this->resource->is_tfa_active, - ]; + ], $this->metadataResource('users.show_metadata_private')); } public function view(Request $request): array diff --git a/app/Models/App.php b/app/Models/App.php index 8d419cef2..fea4b0f5c 100644 --- a/app/Models/App.php +++ b/app/Models/App.php @@ -2,8 +2,12 @@ namespace App\Models; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; use App\Services\Contracts\UrlServiceContract; +use App\Traits\HasMetadata; use App\Traits\HasWebHooks; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Auth\Authenticatable; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; @@ -25,10 +29,12 @@ class App extends Model implements JWTSubject { use HasFactory, + HasCriteria, Authorizable, Authenticatable, HasPermissions, - HasWebHooks; + HasWebHooks, + HasMetadata; protected $guard_name = 'api'; @@ -47,6 +53,11 @@ class App extends Model implements 'role_id', ]; + protected array $criteria = [ + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, + ]; + public function setUrlAttribute(string $url): void { /** @var UrlServiceContract $urlService */ diff --git a/app/Models/Attribute.php b/app/Models/Attribute.php new file mode 100644 index 000000000..c090e2c12 --- /dev/null +++ b/app/Models/Attribute.php @@ -0,0 +1,59 @@ + AttributeType::class, + 'global' => 'boolean', + 'sortable' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + protected array $criteria = [ + 'global', + ]; + + public function options(): HasMany + { + return $this->hasMany(AttributeOption::class); + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'product_attribute') + ->withPivot('id') + ->using(ProductAttribute::class); + } + + public function productSets(): BelongsToMany + { + return $this->belongsToMany(ProductSet::class); + } +} diff --git a/app/Models/AttributeOption.php b/app/Models/AttributeOption.php new file mode 100644 index 000000000..f7be65e1e --- /dev/null +++ b/app/Models/AttributeOption.php @@ -0,0 +1,39 @@ + 'datetime', + 'updated_at' => 'datetime', + ]; + + public function attribute(): BelongsTo + { + return $this->belongsTo(Attribute::class); + } + + public function productAttributes(): BelongsToMany + { + return $this->belongsToMany(ProductAttribute::class); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php index 920adb5a0..f49c7607a 100644 --- a/app/Models/Discount.php +++ b/app/Models/Discount.php @@ -2,11 +2,14 @@ namespace App\Models; +use App\Criteria\DiscountSearch; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; use App\Enums\DiscountType; -use App\SearchTypes\DiscountSearch; +use App\Traits\HasMetadata; use Carbon\Carbon; -use Heseya\Searchable\Searches\Like; -use Heseya\Searchable\Traits\Searchable; +use Heseya\Searchable\Criteria\Like; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -18,7 +21,7 @@ */ class Discount extends Model implements AuditableContract { - use HasFactory, Searchable, SoftDeletes, Auditable; + use HasFactory, HasCriteria, SoftDeletes, Auditable, HasMetadata; protected $fillable = [ 'description', @@ -32,7 +35,6 @@ class Discount extends Model implements AuditableContract protected $casts = [ 'type' => DiscountType::class, - ]; protected $dates = [ @@ -40,10 +42,12 @@ class Discount extends Model implements AuditableContract 'expires_at', ]; - protected array $searchable = [ + protected array $criteria = [ 'description' => Like::class, 'code' => Like::class, 'search' => DiscountSearch::class, + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, ]; public function getUsesAttribute(): int diff --git a/app/Models/Item.php b/app/Models/Item.php index 07d6d382d..c4e46b9fe 100644 --- a/app/Models/Item.php +++ b/app/Models/Item.php @@ -2,12 +2,15 @@ namespace App\Models; -use App\SearchTypes\ItemSearch; -use App\SearchTypes\WhereCreatedBefore; -use App\SearchTypes\WhereSoldOut; -use Heseya\Searchable\Searches\Like; -use Heseya\Searchable\Traits\Searchable; -use Heseya\Sortable\Sortable; +use App\Criteria\ItemSearch; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; +use App\Criteria\WhereCreatedBefore; +use App\Criteria\WhereSoldOut; +use App\Traits\HasMetadata; +use App\Traits\Sortable; +use Heseya\Searchable\Criteria\Like; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -21,7 +24,7 @@ */ class Item extends Model implements AuditableContract { - use SoftDeletes, HasFactory, Searchable, Sortable, Auditable; + use SoftDeletes, HasFactory, HasCriteria, Sortable, Auditable, HasMetadata; protected $fillable = [ 'name', @@ -29,12 +32,14 @@ class Item extends Model implements AuditableContract 'quantity', ]; - protected array $searchable = [ + protected array $criteria = [ 'name' => Like::class, 'sku' => Like::class, 'search' => ItemSearch::class, 'sold_out' => WhereSoldOut::class, 'day' => WhereCreatedBefore::class, + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, ]; protected array $sortable = [ diff --git a/app/Models/Media.php b/app/Models/Media.php index cc7ababbf..bf7b227cd 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\MediaType; +use App\Traits\HasMetadata; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -11,7 +12,7 @@ */ class Media extends Model { - use HasFactory; + use HasFactory, HasMetadata; /** * The table associated with the model. diff --git a/app/Models/Metadata.php b/app/Models/Metadata.php new file mode 100644 index 000000000..348af853f --- /dev/null +++ b/app/Models/Metadata.php @@ -0,0 +1,41 @@ + MetadataValue::class, + 'value_type' => MetadataType::class, + 'public' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function scopePublic($query): Builder + { + return $query->where('public', true); + } + + public function scopePrivate($query): Builder + { + return $query->where('public', false); + } +} diff --git a/app/Models/Option.php b/app/Models/Option.php index af6fd92ce..ad8835e80 100644 --- a/app/Models/Option.php +++ b/app/Models/Option.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\HasMetadata; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -11,7 +12,7 @@ */ class Option extends Model { - use HasFactory; + use HasFactory, HasMetadata; protected $fillable = [ 'name', diff --git a/app/Models/Order.php b/app/Models/Order.php index 69d12d16f..3ade55686 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -5,13 +5,16 @@ use App\Audits\Redactors\AddressRedactor; use App\Audits\Redactors\ShippingMethodRedactor; use App\Audits\Redactors\StatusRedactor; -use App\SearchTypes\OrderSearch; -use App\SearchTypes\WhereCreatedAfter; -use App\SearchTypes\WhereCreatedBefore; -use App\SearchTypes\WhereHasStatusHidden; -use Heseya\Searchable\Searches\Like; -use Heseya\Searchable\Traits\Searchable; -use Heseya\Sortable\Sortable; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; +use App\Criteria\OrderSearch; +use App\Criteria\WhereCreatedAfter; +use App\Criteria\WhereCreatedBefore; +use App\Criteria\WhereHasStatusHidden; +use App\Traits\HasMetadata; +use App\Traits\Sortable; +use Heseya\Searchable\Criteria\Like; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -29,7 +32,7 @@ */ class Order extends Model implements AuditableContract { - use HasFactory, Searchable, Sortable, Notifiable, Auditable; + use HasFactory, HasCriteria, Sortable, Notifiable, Auditable, HasMetadata; protected $fillable = [ 'code', @@ -69,7 +72,7 @@ class Order extends Model implements AuditableContract 'invoice_address_id' => AddressRedactor::class, ]; - protected array $searchable = [ + protected array $criteria = [ 'search' => OrderSearch::class, 'status_id', 'shipping_method_id', @@ -80,6 +83,8 @@ class Order extends Model implements AuditableContract 'paid', 'from' => WhereCreatedAfter::class, 'to' => WhereCreatedBefore::class, + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, ]; protected array $sortable = [ diff --git a/app/Models/PackageTemplate.php b/app/Models/PackageTemplate.php index 8f9e00b69..19539a872 100644 --- a/app/Models/PackageTemplate.php +++ b/app/Models/PackageTemplate.php @@ -2,6 +2,10 @@ namespace App\Models; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; +use App\Traits\HasMetadata; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; /** @@ -9,7 +13,7 @@ */ class PackageTemplate extends Model { - use HasFactory; + use HasFactory, HasCriteria, HasMetadata; protected $fillable = [ 'name', @@ -22,4 +26,9 @@ class PackageTemplate extends Model protected $casts = [ 'weight' => 'float', ]; + + protected array $criteria = [ + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, + ]; } diff --git a/app/Models/Page.php b/app/Models/Page.php index b2dca0686..ccb03c080 100644 --- a/app/Models/Page.php +++ b/app/Models/Page.php @@ -2,8 +2,12 @@ namespace App\Models; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; +use App\Traits\HasMetadata; use App\Traits\HasSeoMetadata; -use Heseya\Sortable\Sortable; +use App\Traits\Sortable; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use OwenIt\Auditing\Auditable; @@ -14,7 +18,7 @@ */ class Page extends Model implements AuditableContract { - use HasFactory, Sortable, Auditable, SoftDeletes, HasSeoMetadata; + use HasFactory, HasCriteria, Sortable, Auditable, SoftDeletes, HasSeoMetadata, HasMetadata; protected $fillable = [ 'order', @@ -33,4 +37,9 @@ class Page extends Model implements AuditableContract 'created_at', 'updated_at', ]; + + protected array $criteria = [ + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, + ]; } diff --git a/app/Models/Permission.php b/app/Models/Permission.php index 05ff6661c..0ebae5b8f 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -2,9 +2,9 @@ namespace App\Models; -use App\SearchTypes\PermissionSearch; +use App\Criteria\PermissionSearch; use App\Traits\HasUuid; -use Heseya\Searchable\Traits\Searchable; +use Heseya\Searchable\Traits\HasCriteria; use Spatie\Permission\Models\Permission as SpatiePermission; /** @@ -12,7 +12,7 @@ */ class Permission extends SpatiePermission { - use Searchable, HasUuid; + use HasCriteria, HasUuid; protected $fillable = [ 'name', @@ -21,7 +21,7 @@ class Permission extends SpatiePermission 'guard_name', ]; - protected array $searchable = [ + protected array $criteria = [ 'assignable' => PermissionSearch::class, ]; } diff --git a/app/Models/Product.php b/app/Models/Product.php index 348270710..a4227d05d 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -2,26 +2,25 @@ namespace App\Models; -use App\SearchTypes\ProductSearch; -use App\SearchTypes\WhereBelongsToManyById; -use App\SearchTypes\WhereInIds; +use App\Services\Contracts\ProductSearchServiceContract; +use App\Traits\HasMetadata; use App\Traits\HasSeoMetadata; -use Heseya\Searchable\Searches\Like; -use Heseya\Searchable\Traits\Searchable; -use Heseya\Sortable\Sortable; +use App\Traits\Sortable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; +use JeroenG\Explorer\Application\Explored; +use Laravel\Scout\Searchable; use OwenIt\Auditing\Auditable; use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** * @mixin IdeHelperProduct */ -class Product extends Model implements AuditableContract +class Product extends Model implements AuditableContract, Explored { - use HasFactory, SoftDeletes, Searchable, Sortable, Auditable, HasSeoMetadata; + use HasFactory, SoftDeletes, Searchable, Sortable, Auditable, HasSeoMetadata, HasMetadata; protected $fillable = [ 'name', @@ -41,11 +40,14 @@ class Product extends Model implements AuditableContract protected $auditInclude = [ 'name', 'slug', - 'price', 'description_html', 'description_short', 'public', 'quantity_step', + 'price_min', + 'price_max', + 'available', + 'order', ]; protected $casts = [ @@ -55,15 +57,6 @@ class Product extends Model implements AuditableContract 'quantity_step' => 'float', ]; - protected array $searchable = [ - 'ids' => WhereInIds::class, - 'name' => Like::class, - 'slug' => Like::class, - 'public', - 'search' => ProductSearch::class, - 'tags' => WhereBelongsToManyById::class, - ]; - protected array $sortable = [ 'id', 'price', @@ -72,11 +65,32 @@ class Product extends Model implements AuditableContract 'updated_at', 'order', 'public', + 'available', ]; - protected string $defaultSortBy = 'created_at'; + protected string $defaultSortBy = 'order'; + protected string $defaultSortDirection = 'desc'; + private ProductSearchServiceContract $searchService; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->searchService = app(ProductSearchServiceContract::class); + } + + public function toSearchableArray(): array + { + return $this->searchService->mapSearchableArray($this); + } + + public function sets(): BelongsToMany + { + return $this->belongsToMany(ProductSet::class, 'product_set_product'); + } + public function items(): BelongsToMany { return $this @@ -104,6 +118,11 @@ public function tags(): BelongsToMany return $this->belongsToMany(Tag::class, 'product_tags'); } + public function requiredSchemas(): BelongsToMany + { + return $this->schemas()->where('required', true); + } + public function schemas(): BelongsToMany { return $this @@ -111,18 +130,38 @@ public function schemas(): BelongsToMany ->orderByPivot('order'); } - public function requiredSchemas(): BelongsToMany + public function scopePublic($query): Builder { - return $this->schemas()->where('required', true); + return $query->where('public', true); } - public function sets(): BelongsToMany + public function attributes(): BelongsToMany { - return $this->belongsToMany(ProductSet::class, 'product_set_product'); + return $this->belongsToMany(Attribute::class, 'product_attribute') + ->withPivot('id') + ->using(ProductAttribute::class); } - public function scopePublic($query): Builder + public function mappableAs(): array { - return $query->where('public', true); + return []; + } + + public function indexSettings(): array + { + return [ + 'analysis' => [ + 'analyzer' => [ + 'standard_lowercase' => [ + 'type' => 'custom', + 'tokenizer' => 'whitespace', + 'filter' => [ + 'lowercase', + 'morfologik_stem', + ], + ], + ], + ], + ]; } } diff --git a/app/Models/ProductAttribute.php b/app/Models/ProductAttribute.php new file mode 100644 index 000000000..47e3ca8c1 --- /dev/null +++ b/app/Models/ProductAttribute.php @@ -0,0 +1,36 @@ +belongsToMany( + AttributeOption::class, + 'product_attribute_attribute_option', + 'product_attribute_id', + 'attribute_option_id', + ); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function attribute(): BelongsTo + { + return $this->belongsTo(Attribute::class); + } +} diff --git a/app/Models/ProductSet.php b/app/Models/ProductSet.php index 697897040..e5968b8bc 100644 --- a/app/Models/ProductSet.php +++ b/app/Models/ProductSet.php @@ -2,10 +2,13 @@ namespace App\Models; -use App\SearchTypes\ProductSetSearch; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; +use App\Criteria\ProductSetSearch; +use App\Traits\HasMetadata; use App\Traits\HasSeoMetadata; -use Heseya\Searchable\Searches\Like; -use Heseya\Searchable\Traits\Searchable; +use Heseya\Searchable\Criteria\Like; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -20,7 +23,7 @@ */ class ProductSet extends Model { - use Searchable, HasFactory, SoftDeletes, HasSeoMetadata; + use HasCriteria, HasFactory, SoftDeletes, HasSeoMetadata, HasMetadata; protected $fillable = [ 'name', @@ -40,11 +43,13 @@ class ProductSet extends Model 'hide_on_index' => 'boolean', ]; - protected array $searchable = [ + protected array $criteria = [ 'name' => Like::class, 'slug' => Like::class, 'search' => ProductSetSearch::class, 'public', + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, ]; public function getSlugOverrideAttribute(): bool @@ -55,6 +60,11 @@ public function getSlugOverrideAttribute(): bool ); } + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + public function getSlugSuffixAttribute(): string { return $this->slugOverride || $this->parent()->doesntExist() ? $this->slug : @@ -77,9 +87,9 @@ public function scopeReversed($query): Builder ->orderBy('order', 'desc'); } - public function parent(): BelongsTo + public function allChildren(): HasMany { - return $this->belongsTo(self::class, 'parent_id'); + return $this->children()->with('allChildren'); } public function children(): HasMany @@ -87,19 +97,19 @@ public function children(): HasMany return $this->hasMany(self::class, 'parent_id'); } - public function childrenPublic(): HasMany + public function attributes(): BelongsToMany { - return $this->children()->public(); + return $this->belongsToMany(Attribute::class); } - public function allChildren(): HasMany + public function allChildrenPublic(): HasMany { - return $this->children()->with('allChildren'); + return $this->childrenPublic()->with('allChildrenPublic'); } - public function allChildrenPublic(): HasMany + public function childrenPublic(): HasMany { - return $this->childrenPublic()->with('allChildrenPublic'); + return $this->children()->public(); } public function products(): BelongsToMany diff --git a/app/Models/Role.php b/app/Models/Role.php index ad7a6b853..a8e45497e 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -2,12 +2,15 @@ namespace App\Models; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; +use App\Criteria\RoleAssignableSearch; +use App\Criteria\RoleSearch; use App\Enums\RoleType; -use App\SearchTypes\RoleAssignableSearch; -use App\SearchTypes\RoleSearch; +use App\Traits\HasMetadata; use App\Traits\HasUuid; -use Heseya\Searchable\Searches\Like; -use Heseya\Searchable\Traits\Searchable; +use Heseya\Searchable\Criteria\Like; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; use OwenIt\Auditing\Auditable; use OwenIt\Auditing\Contracts\Auditable as AuditableContract; @@ -18,7 +21,7 @@ */ class Role extends SpatieRole implements AuditableContract { - use Searchable, HasUuid, HasFactory, Auditable; + use HasCriteria, HasUuid, HasFactory, Auditable, HasMetadata; protected $fillable = [ 'name', @@ -30,10 +33,12 @@ class Role extends SpatieRole implements AuditableContract 'type' => RoleType::class, ]; - protected array $searchable = [ + protected array $criteria = [ 'name' => Like::class, 'description' => Like::class, 'search' => RoleSearch::class, 'assignable' => RoleAssignableSearch::class, + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, ]; } diff --git a/app/Models/Schema.php b/app/Models/Schema.php index 0239230f8..fa4c4b206 100644 --- a/app/Models/Schema.php +++ b/app/Models/Schema.php @@ -2,13 +2,16 @@ namespace App\Models; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; +use App\Criteria\SchemaSearch; use App\Enums\SchemaType; use App\Rules\OptionAvailable; -use App\SearchTypes\SchemaSearch; +use App\Traits\HasMetadata; +use App\Traits\Sortable; use BenSampo\Enum\Exceptions\InvalidEnumKeyException; -use Heseya\Searchable\Searches\Like; -use Heseya\Searchable\Traits\Searchable; -use Heseya\Sortable\Sortable; +use Heseya\Searchable\Criteria\Like; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -23,7 +26,7 @@ */ class Schema extends Model { - use HasFactory, Searchable, Sortable; + use HasFactory, HasCriteria, Sortable, HasMetadata; protected $fillable = [ 'type', @@ -49,11 +52,13 @@ class Schema extends Model 'type' => SchemaType::class, ]; - protected $searchable = [ + protected $criteria = [ 'search' => SchemaSearch::class, 'name' => Like::class, 'hidden', 'required', + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, ]; protected array $sortable = [ diff --git a/app/Models/ShippingMethod.php b/app/Models/ShippingMethod.php index 94eb6b496..972eedadc 100644 --- a/app/Models/ShippingMethod.php +++ b/app/Models/ShippingMethod.php @@ -2,6 +2,10 @@ namespace App\Models; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; +use App\Traits\HasMetadata; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -13,7 +17,7 @@ */ class ShippingMethod extends Model implements AuditableContract { - use HasFactory, Auditable; + use HasFactory, Auditable, HasCriteria, HasMetadata; /** * The attributes that are mass assignable. @@ -37,6 +41,11 @@ class ShippingMethod extends Model implements AuditableContract 'black_list' => 'boolean', ]; + protected array $criteria = [ + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, + ]; + public function orders(): HasMany { return $this->hasMany(Order::class); diff --git a/app/Models/Status.php b/app/Models/Status.php index 7c699f3b5..de22e9b8b 100644 --- a/app/Models/Status.php +++ b/app/Models/Status.php @@ -2,6 +2,10 @@ namespace App\Models; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; +use App\Traits\HasMetadata; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use OwenIt\Auditing\Auditable; @@ -12,7 +16,7 @@ */ class Status extends Model implements AuditableContract { - use HasFactory, Auditable; + use HasFactory, HasCriteria, Auditable, HasMetadata; protected $fillable = [ 'name', @@ -35,6 +39,11 @@ class Status extends Model implements AuditableContract 'no_notifications' => false, ]; + protected array $criteria = [ + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, + ]; + public function orders(): HasMany { return $this->hasMany(Order::class); diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 121273919..878281c82 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -2,9 +2,9 @@ namespace App\Models; -use App\SearchTypes\TagSearch; -use Heseya\Searchable\Searches\Like; -use Heseya\Searchable\Traits\Searchable; +use App\Criteria\TagSearch; +use Heseya\Searchable\Criteria\Like; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -13,14 +13,14 @@ */ class Tag extends Model { - use HasFactory, Searchable; + use HasFactory, HasCriteria; protected $fillable = [ 'name', 'color', ]; - protected array $searchable = [ + protected array $criteria = [ 'name' => Like::class, 'color' => Like::class, 'search' => TagSearch::class, diff --git a/app/Models/User.php b/app/Models/User.php index 31687b43d..268355e92 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,12 +2,15 @@ namespace App\Models; -use App\SearchTypes\UserSearch; -use App\SearchTypes\WhereInIds; +use App\Criteria\MetadataPrivateSearch; +use App\Criteria\MetadataSearch; +use App\Criteria\UserSearch; +use App\Criteria\WhereInIds; +use App\Traits\HasMetadata; use App\Traits\HasWebHooks; -use Heseya\Searchable\Searches\Like; -use Heseya\Searchable\Traits\Searchable; -use Heseya\Sortable\Sortable; +use App\Traits\Sortable; +use Heseya\Searchable\Criteria\Like; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Auth\Authenticatable; use Illuminate\Auth\MustVerifyEmail; use Illuminate\Auth\Passwords\CanResetPassword; @@ -43,10 +46,11 @@ class User extends Model implements HasFactory, HasRoles, SoftDeletes, - Searchable, + HasCriteria, Sortable, Auditable, - HasWebHooks; + HasWebHooks, + HasMetadata; // Bez tego nie działały testy, w których jako aplikacja tworzy się użytkownika z określoną rolą protected $guard_name = 'api'; @@ -65,11 +69,13 @@ class User extends Model implements 'remember_token', ]; - protected array $searchable = [ + protected array $criteria = [ 'name' => Like::class, 'email' => Like::class, 'search' => UserSearch::class, 'ids' => WhereInIds::class, + 'metadata' => MetadataSearch::class, + 'metadata_private' => MetadataPrivateSearch::class, ]; protected array $sortable = [ diff --git a/app/Models/WebHook.php b/app/Models/WebHook.php index 62d6d5da6..e6a99b1c9 100644 --- a/app/Models/WebHook.php +++ b/app/Models/WebHook.php @@ -2,9 +2,9 @@ namespace App\Models; -use Heseya\Searchable\Searches\Like; -use Heseya\Searchable\Traits\Searchable; -use Heseya\Sortable\Sortable; +use App\Traits\Sortable; +use Heseya\Searchable\Criteria\Like; +use Heseya\Searchable\Traits\HasCriteria; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -18,7 +18,7 @@ */ class WebHook extends Model implements AuditableContract { - use HasFactory, SoftDeletes, Searchable, Sortable, Auditable, Notifiable; + use HasFactory, SoftDeletes, HasCriteria, Sortable, Auditable, Notifiable; protected $fillable = [ 'name', @@ -39,7 +39,7 @@ class WebHook extends Model implements AuditableContract 'events' => 'array', ]; - protected array $searchable = [ + protected array $criteria = [ 'name' => Like::class, 'url' => Like::class, ]; diff --git a/app/Observers/AttributeOptionObserver.php b/app/Observers/AttributeOptionObserver.php new file mode 100644 index 000000000..dd8581275 --- /dev/null +++ b/app/Observers/AttributeOptionObserver.php @@ -0,0 +1,61 @@ +attributeService->updateMinMax($attributeOption->attribute); + } + + /** + * Handle the AttributeOption "updated" event. + * + * @param AttributeOption $attributeOption + * + * @return void + */ + public function updated(AttributeOption $attributeOption): void + { + $this->attributeService->updateMinMax($attributeOption->attribute); + } + + /** + * Handle the AttributeOption "deleted" event. + * + * @param AttributeOption $attributeOption + * + * @return void + */ + public function deleted(AttributeOption $attributeOption): void + { + $this->attributeService->updateMinMax($attributeOption->attribute); + } + + /** + * Handle the AttributeOption "restored" event. + * + * @param AttributeOption $attributeOption + * + * @return void + */ + public function restored(AttributeOption $attributeOption): void + { + $this->attributeService->updateMinMax($attributeOption->attribute); + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php deleted file mode 100644 index 99485ab18..000000000 --- a/app/Observers/ProductObserver.php +++ /dev/null @@ -1,21 +0,0 @@ -availabilityService = $availabilityService; - } - - public function created(Product $product) - { - $this->availabilityService->calculateProductAvailability($product); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f73bd5a89..6678ad8e3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,11 +4,15 @@ use App\Services\AnalyticsService; use App\Services\AppService; +use App\Services\AttributeOptionService; +use App\Services\AttributeService; use App\Services\AuditService; use App\Services\AuthService; use App\Services\AvailabilityService; use App\Services\Contracts\AnalyticsServiceContract; use App\Services\Contracts\AppServiceContract; +use App\Services\Contracts\AttributeOptionServiceContract; +use App\Services\Contracts\AttributeServiceContract; use App\Services\Contracts\AuditServiceContract; use App\Services\Contracts\AuthServiceContract; use App\Services\Contracts\AvailabilityServiceContract; @@ -16,12 +20,14 @@ use App\Services\Contracts\EventServiceContract; use App\Services\Contracts\ItemServiceContract; use App\Services\Contracts\MediaServiceContract; +use App\Services\Contracts\MetadataServiceContract; use App\Services\Contracts\NameServiceContract; use App\Services\Contracts\OneTimeSecurityCodeContract; use App\Services\Contracts\OptionServiceContract; use App\Services\Contracts\OrderServiceContract; use App\Services\Contracts\PageServiceContract; use App\Services\Contracts\PermissionServiceContract; +use App\Services\Contracts\ProductSearchServiceContract; use App\Services\Contracts\ProductServiceContract; use App\Services\Contracts\ProductSetServiceContract; use App\Services\Contracts\ReorderServiceContract; @@ -30,6 +36,7 @@ use App\Services\Contracts\SeoMetadataServiceContract; use App\Services\Contracts\SettingsServiceContract; use App\Services\Contracts\ShippingMethodServiceContract; +use App\Services\Contracts\SortServiceContract; use App\Services\Contracts\TokenServiceContract; use App\Services\Contracts\UrlServiceContract; use App\Services\Contracts\UserServiceContract; @@ -38,12 +45,14 @@ use App\Services\EventService; use App\Services\ItemService; use App\Services\MediaService; +use App\Services\MetadataService; use App\Services\NameService; use App\Services\OneTimeSecurityCodeService; use App\Services\OptionService; use App\Services\OrderService; use App\Services\PageService; use App\Services\PermissionService; +use App\Services\ProductSearchService; use App\Services\ProductService; use App\Services\ProductSetService; use App\Services\ReorderService; @@ -52,11 +61,13 @@ use App\Services\SeoMetadataService; use App\Services\SettingsService; use App\Services\ShippingMethodService; +use App\Services\SortService; use App\Services\TokenService; use App\Services\UrlService; use App\Services\UserService; use App\Services\WebHookService; use Illuminate\Support\ServiceProvider; +use Laravel\Scout\Builder; class AppServiceProvider extends ServiceProvider { @@ -88,6 +99,11 @@ class AppServiceProvider extends ServiceProvider ItemServiceContract::class => ItemService::class, OneTimeSecurityCodeContract::class => OneTimeSecurityCodeService::class, AvailabilityServiceContract::class => AvailabilityService::class, + MetadataServiceContract::class => MetadataService::class, + AttributeServiceContract::class => AttributeService::class, + AttributeOptionServiceContract::class => AttributeOptionService::class, + SortServiceContract::class => SortService::class, + ProductSearchServiceContract::class => ProductSearchService::class, ]; /** @@ -110,6 +126,12 @@ public function register(): void public function boot(): void { - // Model::preventLazyLoading(); + Builder::macro('sort', function (?string $sortString = null) { + if ($sortString !== null) { + return app(SortServiceContract::class)->sort($this, $sortString); + } + + return $this; + }); } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index d40d13a6b..931591dcd 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -28,16 +28,15 @@ use App\Listeners\OrderCreatedListener; use App\Listeners\OrderUpdatedStatusListener; use App\Listeners\WebHookEventListener; +use App\Models\AttributeOption; use App\Models\Deposit; use App\Models\ItemProduct; use App\Models\Payment; -use App\Models\Product; +use App\Observers\AttributeOptionObserver; use App\Observers\DepositObserver; use App\Observers\ItemProductObserver; use App\Observers\PaymentObserver; -use App\Observers\ProductObserver; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; -use OwenIt\Auditing\AuditableObserver; class EventServiceProvider extends ServiceProvider { @@ -124,7 +123,7 @@ public function boot(): void parent::boot(); Payment::observe(PaymentObserver::class); Deposit::observe(DepositObserver::class); - Product::observe([ProductObserver::class, AuditableObserver::class]); + AttributeOption::observe(AttributeOptionObserver::class); ItemProduct::observe(ItemProductObserver::class); } } diff --git a/app/Providers/RepositoryServiceProvider.php b/app/Providers/RepositoryServiceProvider.php new file mode 100644 index 000000000..069704d7d --- /dev/null +++ b/app/Providers/RepositoryServiceProvider.php @@ -0,0 +1,21 @@ + ProductRepository::class, + ]; + + public function register(): void + { + foreach (self::CONTRACTS as $abstract => $concrete) { + $this->app->bind($abstract, $concrete); + } + } +} diff --git a/app/Repositories/Contracts/ProductRepositoryContract.php b/app/Repositories/Contracts/ProductRepositoryContract.php new file mode 100644 index 000000000..3e13abd1b --- /dev/null +++ b/app/Repositories/Contracts/ProductRepositoryContract.php @@ -0,0 +1,11 @@ + 'filterIds', + 'slug' => 'must', + 'name' => 'must', + 'public' => 'filter', + 'available' => 'filter', + 'sets' => 'filterSlug', + 'tags' => 'filterId', + 'metadata' => 'filterMeta', + 'metadata_private' => 'filterMeta', + ]; + + public function search(ProductSearchDto $dto): LengthAwarePaginator + { + $query = Product::search($dto->getSearch()) + ->sort($dto->getSort()); + + $hide_on_index = true; + + foreach ($dto->toArray() as $key => $value) { + if (array_key_exists($key, self::CRITERIA)) { + $query = $this->{self::CRITERIA[$key]}($query, $key, $value); + $hide_on_index = false; + } + } + + if (Gate::denies('products.show_hidden')) { + $query->filter(new Term('public', true)); + + if ($hide_on_index && $dto->getSearch() === null) { + $query->filter(new Term('hide_on_index', false)); + } + } + + return $query->paginate(Config::get('pagination.per_page')); + } + + private function must(Builder $query, string $key, string|int|float|bool $value): Builder + { + return $query->must(new Matching($key, $value)); + } + + private function filter(Builder $query, string $key, string|int|float|bool $value): Builder + { + return $query->filter(new Term($key, $value)); + } + + private function filterSlug(Builder $query, string $key, array $slugs): Builder + { + return $query->filter(new Terms("{$key}.slug", $slugs)); + } + + private function filterId(Builder $query, string $key, array $ids): Builder + { + return $query->filter(new Terms("{$key}.id", $ids)); + } + + private function filterIds(Builder $query, string $key, string $ids): Builder + { + $ids = Str::of($ids)->explode(','); + + $query->filter(new Terms('id', $ids->toArray())); + + return $query; + } + + private function filterMeta(Builder $query, string $key, array $meta): Builder + { + $query->filter(new Terms("${key}.name", array_keys($meta))); + $query->filter(new Terms("${key}.value", array_values($meta))); + + return $query; + } +} diff --git a/app/Rules/AttributeOptionExist.php b/app/Rules/AttributeOptionExist.php new file mode 100644 index 000000000..778a76154 --- /dev/null +++ b/app/Rules/AttributeOptionExist.php @@ -0,0 +1,26 @@ +attributeId = Str::between($attribute, '.', '.'); + + $attributeOption = AttributeOption::where('id', $value)->where('attribute_id', $this->attributeId)->first(); + + return $attributeOption !== null; + } + + public function message() + { + return 'This option :value is not available in attribute :attribute'; + } +} diff --git a/app/Rules/CanShowPrivateMetadata.php b/app/Rules/CanShowPrivateMetadata.php new file mode 100644 index 000000000..4bb18c083 --- /dev/null +++ b/app/Rules/CanShowPrivateMetadata.php @@ -0,0 +1,32 @@ +message = 'Attribute :attribute not found'; + return false; + } + + if ($attributeModel->type->is(AttributeType::MULTI_CHOICE_OPTION)) { + $this->message = 'Attribute :attribute must have at least one option'; + return count($value) >= 1; + } + + $this->message = 'Attribute :attribute must have one option'; + return count($value) === 1; + } + + public function message() + { + return $this->message; + } +} diff --git a/app/SearchTypes/ProductSearch.php b/app/SearchTypes/ProductSearch.php deleted file mode 100644 index 4ae097239..000000000 --- a/app/SearchTypes/ProductSearch.php +++ /dev/null @@ -1,19 +0,0 @@ -where(function (Builder $query): void { - $query->where('id', 'LIKE', '%' . $this->value . '%') - ->orWhere('slug', 'LIKE', '%' . $this->value . '%') - ->orWhere('name', 'LIKE', '%' . $this->value . '%') - ->orWhere('description_html', 'LIKE', '%' . $this->value . '%'); - }); - } -} diff --git a/app/SearchTypes/WhereHasStatusHidden.php b/app/SearchTypes/WhereHasStatusHidden.php deleted file mode 100644 index 516c01d1d..000000000 --- a/app/SearchTypes/WhereHasStatusHidden.php +++ /dev/null @@ -1,22 +0,0 @@ -whereHas('status', function (Builder $query) { - return $query->where('hidden', $this->value); - }); - - if (!$this->value) { - $query->orWhereDoesntHave('status'); - } - - return $query; - } -} diff --git a/app/Services/AttributeOptionService.php b/app/Services/AttributeOptionService.php new file mode 100644 index 000000000..bb6d8b712 --- /dev/null +++ b/app/Services/AttributeOptionService.php @@ -0,0 +1,48 @@ + AttributeOption::withTrashed()->where('attribute_id', '=', $attributeId)->count() + 1, + 'attribute_id' => $attributeId, + ], + $dto->toArray(), + ); + + return AttributeOption::create($data); + } + + public function updateOrCreate(string $attributeId, AttributeOptionDto $dto): AttributeOption + { + if ($dto->getId() !== null && !$dto->getId() instanceof Missing) { + $attributeOption = AttributeOption::findOrFail($dto->getId()); + $attributeOption->update($dto->toArray()); + + return $attributeOption; + } + + return $this->create($attributeId, $dto); + } + + public function delete(AttributeOption $attributeOption): void + { + $attributeOption->delete(); + } + + public function deleteAll(string $attributeId): void + { + AttributeOption::query() + ->where('attribute_id', '=', $attributeId) + ->delete(); + } +} diff --git a/app/Services/AttributeService.php b/app/Services/AttributeService.php new file mode 100644 index 000000000..e1e083068 --- /dev/null +++ b/app/Services/AttributeService.php @@ -0,0 +1,83 @@ +toArray()); + + $this->processAttributeOptions($attribute, $dto); + + return $attribute; + } + + public function update(Attribute $attribute, AttributeDto $dto): Attribute + { + $attribute->update($dto->toArray()); + + $this->processAttributeOptions($attribute, $dto); + + return $attribute; + } + + public function delete(Attribute $attribute): void + { + $this->attributeOptionService->deleteAll($attribute->getKey()); + + $attribute->delete(); + } + + public function sync(Product $product, array $data): void + { + $attributes = Arr::divide($data)[0]; + + $product->attributes()->sync($attributes); + $product->attributes->each(fn ($attribute) => $attribute->pivot->options()->sync($data[$attribute->getKey()])); + } + + public function updateMinMax(Attribute $attribute): void + { + $attribute->refresh(); + + if ($attribute->type->value === AttributeType::NUMBER) { + $attribute->min_number = $attribute->options->min('value_number'); + $attribute->max_number = $attribute->options->max('value_number'); + } + + if ($attribute->type->value === AttributeType::DATE) { + $attribute->min_date = $attribute->options->min('value_date'); + $attribute->max_date = $attribute->options->max('value_date'); + } + + $attribute->update(); + } + + protected function processAttributeOptions(Attribute &$attribute, AttributeDto $dto): Attribute + { + $attribute->options + ->whereNotIn('id', array_map(fn ($option) => $option->getId(), $dto->getOptions())) + ->each( + fn ($missingOption) => $this->attributeOptionService->delete($missingOption) + ); + + foreach ($dto->getOptions() as $option) { + $this->attributeOptionService->updateOrCreate($attribute->getKey(), $option); + } + + return $attribute->refresh(); + } +} diff --git a/app/Services/AvailabilityService.php b/app/Services/AvailabilityService.php index 1c6504d2b..52b861386 100644 --- a/app/Services/AvailabilityService.php +++ b/app/Services/AvailabilityService.php @@ -71,40 +71,37 @@ public function calculateSchemaAvailability(Schema $schema): void } public function calculateProductAvailability(Product $product): void + { + $product->update([ + 'available' => $this->isProductAvaiable($product), + ]); + } + + public function isProductAvaiable(Product $product): bool { //If every product's item quantity is greater or equal to pivot quantity or product has no schemas //then product is available if ( - ($product->items->isNotEmpty() - && $product->items->every(fn ($item) => $item->pivot->quantity <= $item->quantity)) - || !$product->schemas()->exists()) { - $product->update([ - 'available' => true, - ]); - return; + $product->schemas->isEmpty() || + ($product->items->isNotEmpty() && + $product->items->every(fn ($item) => $item->pivot->quantity <= $item->quantity)) + ) { + return true; } $requiredSelectSchemas = $product->requiredSchemas->where('type.value', SchemaType::SELECT); if ($requiredSelectSchemas->isEmpty()) { - $product->update([ - 'available' => true, - ]); - - return; + return true; } $hasAvailablePermutations = $this->checkPermutations($requiredSelectSchemas); if ($hasAvailablePermutations) { - $product->update([ - 'available' => true, - ]); - } else { - $product->update([ - 'available' => false, - ]); + return true; } + + return false; } public function checkPermutations(Collection $schemas): bool diff --git a/app/Services/Contracts/AttributeOptionServiceContract.php b/app/Services/Contracts/AttributeOptionServiceContract.php new file mode 100644 index 000000000..7962b77dc --- /dev/null +++ b/app/Services/Contracts/AttributeOptionServiceContract.php @@ -0,0 +1,17 @@ +reorderService = $reorderService; } - public function sync(Product $product, array $media = []): void + public function sync(Product $product, array $media): void { $operations = $product->media()->sync($this->reorderService->reorder($media)); @@ -37,6 +37,22 @@ public function sync(Product $product, array $media = []): void } } + public function destroy(Media $media): void + { + if ($media->products()->exists()) { + Gate::authorize('products.edit'); + } + + $response = Http::withHeaders(['x-api-key' => Config::get('silverbox.key')]) + ->delete($media->url); + + if ($response->failed()) { + throw new MediaCriticalException('CDN responded with an error'); + } + + $media->forceDelete(); + } + public function store(UploadedFile $file): Media { $response = Http::attach('file', $file->getContent(), 'file') @@ -69,22 +85,6 @@ public function update(Media $media, MediaUpdateDto $dto): Media return $media; } - public function destroy(Media $media): void - { - if ($media->products()->exists()) { - Gate::authorize('products.edit'); - } - - $response = Http::withHeaders(['x-api-key' => Config::get('silverbox.key')]) - ->delete($media->url); - - if ($response->failed()) { - throw new MediaCriticalException('CDN responded with an error'); - } - - $media->forceDelete(); - } - private function getMediaType(string $extension): int { return match ($extension) { diff --git a/app/Services/MetadataService.php b/app/Services/MetadataService.php new file mode 100644 index 000000000..9f0196048 --- /dev/null +++ b/app/Services/MetadataService.php @@ -0,0 +1,54 @@ +updateOrCreate($model, $meta); + } + } + + public function updateOrCreate(Model|Role $model, MetadataDto $dto): void + { + $query = $dto->isPublic() ? $model->metadata() : $model->metadataPrivate(); + + if ($dto->getValue() === null) { + $query->where('name', $dto->getName())->delete(); + + return; + } + + $query->updateOrCreate( + ['name' => $dto->getName()], + $dto->toArray(), + ); + } + + public function returnModel(array $routeSegments): Model|Role|null + { + $segment = Collection::make($routeSegments)->first(); + $className = 'App\\Models\\' . Str::studly(Str::singular($segment)); + + if (class_exists($className)) { + return new $className(); + } + + $className = 'App\\Models\\' . Str::studly($segment); + + if (class_exists($className)) { + return new $className(); + } + + return null; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 33e268f48..e50ba4c13 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -91,9 +91,9 @@ public function update(OrderUpdateDto $dto, Order $order): JsonResponse public function indexUserOrder(OrderIndexDto $dto): LengthAwarePaginator { - return Order::search(['user_id' => Auth::id()] + $dto->getSearchCriteria()) + return Order::searchByCriteria(['user_id' => Auth::id()] + $dto->getSearchCriteria()) ->sort($dto->getSort()) - ->with(['products', 'discounts', 'payments']) + ->with(['products', 'discounts', 'payments', 'metadata']) ->paginate(Config::get('pagination.per_page')); } diff --git a/app/Services/PageService.php b/app/Services/PageService.php index cf9e315d3..15029fc1e 100644 --- a/app/Services/PageService.php +++ b/app/Services/PageService.php @@ -28,9 +28,11 @@ public function authorize(Page $page): void } } - public function getPaginated(): LengthAwarePaginator + public function getPaginated(?array $search): LengthAwarePaginator { - $query = Page::query()->with('seo'); + $query = Page::query() + ->searchByCriteria($search) + ->with(['seo', 'metadata']); if (!Auth::user()->can('pages.show_hidden')) { $query->where('public', true); diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index 8ab292da7..d837e9382 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -11,7 +11,7 @@ class PermissionService implements PermissionServiceContract public function getAll(?bool $assignable): Collection { if ($assignable !== null) { - return Permission::search([ + return Permission::searchByCriteria([ 'assignable' => $assignable, ])->get(); } diff --git a/app/Services/ProductSearchService.php b/app/Services/ProductSearchService.php new file mode 100644 index 000000000..b7567bfc3 --- /dev/null +++ b/app/Services/ProductSearchService.php @@ -0,0 +1,118 @@ + $product->getKey(), + 'name' => $product->name, + 'slug' => $product->slug, + 'hide_on_index' => $this->mapHideOnIndex($product), + 'available' => $product->available, + 'price' => $product->price, + 'price_min' => $product->price_min, + 'price_max' => $product->price_max, + 'public' => $product->public, + 'description' => strip_tags($product->description_html), + 'description_short' => $product->description_short, + 'created_at' => $product->created_at, + 'updated_at' => $product->updated_at, + 'order' => $product->order, + 'tags' => $product->tags->map(fn (Tag $tag): array => $this->mapTag($tag))->toArray(), + 'sets' => $this->mapSets($product), + 'attributes' => ProductAttribute::where('product_id', $product->getKey()) + ->with('options') + ->get() + ->map(fn (ProductAttribute $attribute): array => $this->mapAttribute($attribute)) + ->toArray(), + 'metadata' => $product->metadata + ->map(fn (Metadata $meta): array => $this->mapMeta($meta)) + ->toArray(), + 'metadata_private' => $product->metadataPrivate + ->map(fn (Metadata $meta): array => $this->mapMeta($meta)) + ->toArray(), + ]; + } + + private function mapHideOnIndex(Product $product): bool + { + $sets = $this->productSetService->flattenParentsSetsTree($product->sets); + + foreach ($sets as $set) { + if ($set->hide_on_index) { + return true; + } + } + + return false; + } + + private function mapTag(Tag $tag): array + { + return [ + 'id' => $tag->getKey(), + 'name' => $tag->name, + ]; + } + + private function mapSets(Product $product): array + { + $sets = $this->productSetService->flattenParentsSetsTree($product->sets); + + return $sets->map(fn (ProductSet $set): array => $this->mapSet($set))->toArray(); + } + + private function mapSet(ProductSet $set): array + { + return [ + 'id' => $set->getKey(), + 'name' => $set->name, + 'slug' => $set->slug, + 'description' => strip_tags($set->description_html), + ]; + } + + private function mapAttribute(ProductAttribute $attribute): array + { + return [ + 'id' => $attribute->attribute->getKey(), + 'name' => $attribute->attribute->name, + 'slug' => $attribute->attribute->slug, + 'type' => $attribute->attribute->type, + 'values' => $attribute->options->map(function ($option): array { + return [ + 'id' => $option->getKey(), + 'name' => $option->name, + 'value_number' => $option->value_number, + 'value_date' => $option->value_date, + ]; + })->toArray(), + ]; + } + + private function mapMeta(Metadata $meta): array + { + return [ + 'id' => $meta->getKey(), + 'name' => $meta->name, + 'value' => (string) $meta->value, + 'value_type' => $meta->value_type, + ]; + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index 2e50fa8a1..7a805e384 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -2,41 +2,138 @@ namespace App\Services; +use App\Dtos\ProductCreateDto; +use App\Dtos\ProductUpdateDto; use App\Enums\SchemaType; +use App\Events\ProductCreated; +use App\Events\ProductDeleted; +use App\Events\ProductUpdated; use App\Models\Option; use App\Models\Product; use App\Models\Schema; +use App\Services\Contracts\AttributeServiceContract; +use App\Services\Contracts\AvailabilityServiceContract; +use App\Services\Contracts\MediaServiceContract; +use App\Services\Contracts\MetadataServiceContract; use App\Services\Contracts\ProductServiceContract; +use App\Services\Contracts\SchemaServiceContract; +use App\Services\Contracts\SeoMetadataServiceContract; +use Heseya\Dto\Missing; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; class ProductService implements ProductServiceContract { - public function assignItems(Product $product, array|null $itemsIds): Product + public function __construct( + private MediaServiceContract $mediaService, + private SchemaServiceContract $schemaService, + private SeoMetadataServiceContract $seoMetadataService, + private AvailabilityServiceContract $availabilityService, + private MetadataServiceContract $metadataService, + private AttributeServiceContract $attributeService, + ) { + } + + private function setup(Product $product, ProductCreateDto|ProductUpdateDto $dto): Product { - if ($itemsIds !== null) { - $product->items()->detach(); - $items = new Collection($itemsIds); - $items->each( - fn ($item) => $product->items()->attach($item['id'], ['quantity' => $item['quantity']]) - ); + if (!($dto->getSchemas() instanceof Missing)) { + $this->schemaService->sync($product, $dto->getSchemas()); + } + + if (!($dto->getSets() instanceof Missing)) { + $product->sets()->sync($dto->getSets()); + } + + if (!($dto->getItems() instanceof Missing)) { + $this->assignItems($product, $dto->getItems()); + } + + if (!($dto->getMedia() instanceof Missing)) { + $this->mediaService->sync($product, $dto->getMedia()); } - return $product->refresh(); + if (!($dto->getTags() instanceof Missing)) { + $product->tags()->sync($dto->getTags()); + } + + if (!($dto->getSeo() instanceof Missing)) { + $product->seo()->save($this->seoMetadataService->create($dto->getSeo())); + } + + if (!($dto->getMetadata() instanceof Missing)) { + $this->metadataService->sync($product, $dto->getMetadata()); + } + + if (!($dto->getAttributes() instanceof Missing)) { + $this->attributeService->sync($product, $dto->getAttributes()); + } + + [$priceMin, $priceMax] = $this->getMinMaxPrices($product); + $product->price_min = $priceMin; + $product->price_max = $priceMax; + $product->available = $this->availabilityService->isProductAvaiable($product); + + return $product; + } + + public function create(ProductCreateDto $dto): Product + { + DB::beginTransaction(); + + $product = Product::create($dto->toArray()); + $product = $this->setup($product, $dto); + $product->save(); + + DB::commit(); + ProductCreated::dispatch($product); + + return $product; } public function getMinMaxPrices(Product $product): array { - $schemaMinMax = $this->getSchemasPrices( + [$schemaMin, $schemaMax] = $this->getSchemasPrices( clone $product->schemas, clone $product->schemas, ); return [ - $product->price + $schemaMinMax[0], - $product->price + $schemaMinMax[1], + $product->price + $schemaMin, + $product->price + $schemaMax, ]; } + public function update(Product $product, ProductUpdateDto $dto): Product + { + DB::beginTransaction(); + + $product->fill($dto->toArray()); + $product = $this->setup($product, $dto); + $product->save(); + + DB::commit(); + ProductUpdated::dispatch($product); + + return $product; + } + + public function delete(Product $product): void + { + ProductDeleted::dispatch($product); + + DB::beginTransaction(); + + $this->mediaService->sync($product, []); + + $product->delete(); + + if ($product->seo !== null) { + $this->seoMetadataService->delete($product->seo); + } + + DB::commit(); + } + public function updateMinMaxPrices(Product $product): void { $productMinMaxPrices = $this->getMinMaxPrices($product); @@ -46,6 +143,19 @@ public function updateMinMaxPrices(Product $product): void ]); } + private function assignItems(Product $product, ?array $items): void + { + $items = Collection::make($items)->mapWithKeys(function (array $item): array { + return [ + $item['id'] => [ + 'quantity' => $item['quantity'], + ], + ]; + }); + + $product->items()->sync($items); + } + private function getSchemasPrices( Collection $allSchemas, Collection $remainingSchemas, diff --git a/app/Services/ProductSetService.php b/app/Services/ProductSetService.php index 3ad24cf39..ebb27bac1 100644 --- a/app/Services/ProductSetService.php +++ b/app/Services/ProductSetService.php @@ -35,7 +35,8 @@ public function authorize(ProductSet $set): void public function searchAll(array $attributes, bool $root): Collection { - $query = ProductSet::search($attributes); + $query = ProductSet::searchByCriteria($attributes) + ->with('metadata'); if (!Auth::user()->can('product_sets.show_hidden')) { $query->public(); @@ -75,6 +76,11 @@ public function create(ProductSetDto $dto): ProductSet 'public_parent' => $publicParent, ]); + $attributes = Collection::make($dto->getAttributesIds()); + if ($attributes->isNotEmpty()) { + $set->attributes()->sync($attributes); + } + $children = Collection::make($dto->getChildrenIds()); if ($children->isNotEmpty()) { $children = $children->map(fn ($id) => ProductSet::findOrFail($id)); @@ -170,6 +176,11 @@ public function update(ProductSet $set, ProductSetDto $dto): ProductSet 'public_parent' => $publicParent, ]); + if ($dto->getAttributesIds() !== null) { + $attributes = Collection::make($dto->getAttributesIds()); + $set->attributes()->sync($attributes); + } + $seo = $set->seo; if ($seo !== null) { $this->seoMetadataService->update($dto->getSeo(), $seo); @@ -234,4 +245,20 @@ public function flattenSetsTree(Collection $sets, string $relation): Collection return $subsets->flatten()->concat($sets); } + + /** + * Recursive get all parents of set collection if parent exists. + */ + public function flattenParentsSetsTree(Collection $sets): Collection + { + $subsets = Collection::make(); + + foreach ($sets as $set) { + if ($set->parent) { + $subsets = $subsets->merge($this->flattenParentsSetsTree(Collection::make([$set->parent]))); + } + } + + return $subsets->flatten()->concat($sets); + } } diff --git a/app/Services/RoleService.php b/app/Services/RoleService.php index 1e5483ada..0b3dd47b8 100644 --- a/app/Services/RoleService.php +++ b/app/Services/RoleService.php @@ -18,7 +18,7 @@ class RoleService implements RoleServiceContract { public function search(RoleSearchDto $searchDto, int $limit): LengthAwarePaginator { - return Role::search($searchDto->toArray())->paginate($limit); + return Role::searchByCriteria($searchDto->toArray())->paginate($limit); } public function create(RoleCreateDto $dto): Role diff --git a/app/Services/ShippingMethodService.php b/app/Services/ShippingMethodService.php index c3d1e2df0..537705e66 100644 --- a/app/Services/ShippingMethodService.php +++ b/app/Services/ShippingMethodService.php @@ -12,9 +12,12 @@ class ShippingMethodService implements ShippingMethodServiceContract { - public function index(?string $country, float $cartValue): Collection + public function index(?array $search, ?string $country, float $cartValue): Collection { - $query = ShippingMethod::query()->orderBy('order'); + $query = ShippingMethod::query() + ->searchByCriteria($search) + ->with('metadata') + ->orderBy('order'); if (!Auth::user()->can('shipping_methods.show_hidden')) { $query->where('public', true); diff --git a/app/Services/SortService.php b/app/Services/SortService.php new file mode 100644 index 000000000..8dc6331ef --- /dev/null +++ b/app/Services/SortService.php @@ -0,0 +1,44 @@ +sort($query, $sortString, $query->model->getSortable()); + } + + public function sort(Builder|ScoutBuilder $query, string $sortString, array $sortable): Builder|ScoutBuilder + { + $sort = explode(',', $sortString); + + foreach ($sort as $option) { + $option = explode(':', $option); + + $field = $option[0]; + Validator::make( + $option, + [ + '0' => ['required', 'in:' . implode(',', $sortable)], + '1' => ['in:asc,desc'], + ], + [ + 'required' => 'You must specify sort field.', + '0.in' => "You can't sort by ${field} field.", + '1.in' => "Only asc|desc sorting directions are allowed on field ${field}.", + ] + )->validate(); + + $order = count($option) > 1 ? $option[1] : 'asc'; + $query->orderBy($field, $order); + } + + return $query; + } +} diff --git a/app/Services/UserService.php b/app/Services/UserService.php index e06339481..78834eeb0 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -19,8 +19,9 @@ class UserService implements UserServiceContract { public function index(array $search, ?string $sort, int $limit): LengthAwarePaginator { - return User::search($search) + return User::searchByCriteria($search) ->sort($sort) + ->with('metadata') ->paginate($limit); } diff --git a/app/Services/WebHookService.php b/app/Services/WebHookService.php index fed0504ec..3edd0b34b 100644 --- a/app/Services/WebHookService.php +++ b/app/Services/WebHookService.php @@ -11,7 +11,7 @@ class WebHookService implements WebHookServiceContract { public function searchAll(array $attributes, ?string $sort): Collection { - return WebHook::search($attributes)->sort($sort)->get(); + return WebHook::searchByCriteria($attributes)->sort($sort)->get(); } public function create(array $request): WebHook diff --git a/app/Traits/HasMetadata.php b/app/Traits/HasMetadata.php new file mode 100644 index 000000000..5b9f1e773 --- /dev/null +++ b/app/Traits/HasMetadata.php @@ -0,0 +1,23 @@ +morphMany(Metadata::class, 'model', 'model_type', 'model_id') + ->public(); + } + + public function metadataPrivate(): MorphMany + { + return $this + ->morphMany(Metadata::class, 'model', 'model_type', 'model_id') + ->private(); + } +} diff --git a/app/Traits/MetadataResource.php b/app/Traits/MetadataResource.php new file mode 100644 index 000000000..6b7147f82 --- /dev/null +++ b/app/Traits/MetadataResource.php @@ -0,0 +1,32 @@ +processMetadata($this->resource->metadata); + + if ($privateMetadataPermission !== null && Gate::allows($privateMetadataPermission)) { + $data['metadata_private'] = $this->processMetadata($this->resource->metadataPrivate); + } + + return $data; + } + + private function processMetadata(Collection $data) + { + /** + * Special workaround for frond-end requirements + * */ + if ($data->count() <= 0) { + return (object) []; + } + + return $data->mapWithKeys(fn ($meta) => [$meta->name => $meta->value]); + } +} diff --git a/app/Traits/MetadataRules.php b/app/Traits/MetadataRules.php new file mode 100644 index 000000000..52ccb8e54 --- /dev/null +++ b/app/Traits/MetadataRules.php @@ -0,0 +1,14 @@ + ['array'], + 'metadata_private' => ['array'], + ]; + } +} diff --git a/app/Traits/PermissionUtility.php b/app/Traits/PermissionUtility.php new file mode 100644 index 000000000..3bef59ef2 --- /dev/null +++ b/app/Traits/PermissionUtility.php @@ -0,0 +1,34 @@ +getPermissionPrefix($model)}.${ability}"); + } + + protected function deniesAbilityByModel(string $ability, $model): bool + { + return !$this->allowsAbilityByModel($ability, $model); + } + + /** + * Allows to change returned prefix to desired model if is different to table name + * + * @param $model + * + * @return string + * */ + protected function getPermissionPrefix($model): string + { + return match ($model::class) { + PackageTemplate::class => 'packages', + default => $model->getTable(), + }; + } +} diff --git a/app/Traits/SeoMetadataRules.php b/app/Traits/SeoMetadataRules.php deleted file mode 100644 index f34c9ae41..000000000 --- a/app/Traits/SeoMetadataRules.php +++ /dev/null @@ -1,21 +0,0 @@ - ['nullable', 'string', 'max:255'], - 'seo.description' => ['nullable', 'string', 'max:1000'], - 'seo.keywords' => ['nullable', 'array'], - 'seo.og_image_id' => ['nullable', 'uuid', 'exists:media,id'], - 'seo.twitter_card' => ['nullable', new EnumValue(TwitterCardType::class, false)], - 'seo.no_index' => ['nullable', 'boolean'], - ]; - } -} diff --git a/app/Traits/SeoRules.php b/app/Traits/SeoRules.php new file mode 100644 index 000000000..77fb3352f --- /dev/null +++ b/app/Traits/SeoRules.php @@ -0,0 +1,21 @@ + ['nullable', 'string', 'max:255'], + "{$prefix}description" => ['nullable', 'string', 'max:1000'], + "{$prefix}keywords" => ['nullable', 'array'], + "{$prefix}og_image_id" => ['nullable', 'uuid', 'exists:media,id'], + "{$prefix}twitter_card" => ['nullable', new EnumValue(TwitterCardType::class, false)], + "{$prefix}no_index" => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Traits/Sortable.php b/app/Traits/Sortable.php new file mode 100644 index 000000000..b663cabfc --- /dev/null +++ b/app/Traits/Sortable.php @@ -0,0 +1,40 @@ +sort($query, $sortString, $this->getSortable()); + } + + return $query->orderBy( + $this->getDefaultSortBy(), + $this->getDefaultSortDirection(), + ); + } + + public function getSortable(): array + { + // @phpstan-ignore-next-line + return $this->sortable ?? []; + } + + public function getDefaultSortBy(): string + { + // @phpstan-ignore-next-line + return $this->defaultSortBy ?? 'created_at'; + } + + public function getDefaultSortDirection(): string + { + // @phpstan-ignore-next-line + return $this->defaultSortDirection ?? 'asc'; + } +} diff --git a/composer.json b/composer.json index 23878f2f8..728be2055 100644 --- a/composer.json +++ b/composer.json @@ -10,11 +10,12 @@ "guzzlehttp/guzzle": "^7.4", "heseya/2fa": "^2.0", "heseya/dto": "^1.0", - "heseya/laravel-searchable": "^1.0", - "heseya/pagination": "^1.0", + "heseya/laravel-searchable": "^2.0", + "heseya/pagination": "^1.0.2", "heseya/resource": "^1.0", - "heseya/sortable": "^1.0", + "bvlinsky/explorer": "^3.0", "laravel/framework": "^9.4", + "laravel/scout": "^9.4.5", "league/html-to-markdown": "^5.1", "league/omnipay": "^3.2", "omnipay/common": "^3.2", @@ -111,14 +112,6 @@ { "type": "path", "url": "./heseya/resource" - }, - { - "type": "path", - "url": "./heseya/pagination" - }, - { - "type": "path", - "url": "./heseya/sortable" } ] } diff --git a/composer.lock b/composer.lock index 0df992727..bea60a23f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b4becf595e9e34f9bbe5b542326328dd", + "content-hash": "76746346c63e2e8bf9e8e96d9aff4263", "packages": [ { "name": "bensampo/laravel-enum", @@ -150,6 +150,72 @@ ], "time": "2021-08-15T20:50:18+00:00" }, + { + "name": "bvlinsky/explorer", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/bvlinsky/Explorer.git", + "reference": "72333facc97df1b1ea584ea073d92d0835873b90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bvlinsky/Explorer/zipball/72333facc97df1b1ea584ea073d92d0835873b90", + "reference": "72333facc97df1b1ea584ea073d92d0835873b90", + "shasum": "" + }, + "require": { + "elasticsearch/elasticsearch": "^7.16", + "illuminate/support": "^9.0", + "laravel/scout": "^9.0", + "php": "8.0.*||8.1.*", + "webmozart/assert": "^1.10" + }, + "require-dev": { + "infection/infection": "^0.26", + "mockery/mockery": "^1.4", + "phpunit/phpunit": "~9.0", + "symplify/easy-coding-standard": "^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "JeroenG\\Explorer\\ExplorerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "JeroenG\\Explorer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "EUPL-1.2" + ], + "authors": [ + { + "name": "Jeroen", + "email": "jeroengjeroeng@gmail.com", + "homepage": "https://jeroeng.dev" + } + ], + "description": "Next-gen Elasticsearch driver for Laravel Scout.", + "homepage": "https://jeroen-g.github.io/Explorer/", + "keywords": [ + "elastic", + "elasticsearch", + "explorer", + "laravel", + "scout", + "search" + ], + "support": { + "source": "https://github.com/bvlinsky/Explorer/tree/3.0.0" + }, + "time": "2022-03-26T15:24:54+00:00" + }, { "name": "clue/stream-filter", "version": "v1.6.0", @@ -1482,6 +1548,183 @@ ], "time": "2021-10-11T09:18:27+00:00" }, + { + "name": "elasticsearch/elasticsearch", + "version": "v7.17.0", + "source": { + "type": "git", + "url": "https://github.com/elastic/elasticsearch-php.git", + "reference": "1890f9d7fde076b5a3ddcf579a802af05b2e781b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/1890f9d7fde076b5a3ddcf579a802af05b2e781b", + "reference": "1890f9d7fde076b5a3ddcf579a802af05b2e781b", + "shasum": "" + }, + "require": { + "ext-json": ">=1.3.7", + "ezimuel/ringphp": "^1.1.2", + "php": "^7.3 || ^8.0", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "ext-yaml": "*", + "ext-zip": "*", + "mockery/mockery": "^1.2", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.4", + "symfony/finder": "~4.0" + }, + "suggest": { + "ext-curl": "*", + "monolog/monolog": "Allows for client-level logging and tracing" + }, + "type": "library", + "autoload": { + "files": [ + "src/autoload.php" + ], + "psr-4": { + "Elasticsearch\\": "src/Elasticsearch/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0", + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Zachary Tong" + }, + { + "name": "Enrico Zimuel" + } + ], + "description": "PHP Client for Elasticsearch", + "keywords": [ + "client", + "elasticsearch", + "search" + ], + "support": { + "issues": "https://github.com/elastic/elasticsearch-php/issues", + "source": "https://github.com/elastic/elasticsearch-php/tree/v7.17.0" + }, + "time": "2022-02-03T13:40:04+00:00" + }, + { + "name": "ezimuel/guzzlestreams", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/ezimuel/guzzlestreams.git", + "reference": "abe3791d231167f14eb80d413420d1eab91163a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezimuel/guzzlestreams/zipball/abe3791d231167f14eb80d413420d1eab91163a8", + "reference": "abe3791d231167f14eb80d413420d1eab91163a8", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Fork of guzzle/streams (abandoned) to be used with elasticsearch-php", + "homepage": "http://guzzlephp.org/", + "keywords": [ + "Guzzle", + "stream" + ], + "support": { + "source": "https://github.com/ezimuel/guzzlestreams/tree/3.0.1" + }, + "time": "2020-02-14T23:11:50+00:00" + }, + { + "name": "ezimuel/ringphp", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ezimuel/ringphp.git", + "reference": "92b8161404ab1ad84059ebed41d9f757e897ce74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezimuel/ringphp/zipball/92b8161404ab1ad84059ebed41d9f757e897ce74", + "reference": "92b8161404ab1ad84059ebed41d9f757e897ce74", + "shasum": "" + }, + "require": { + "ezimuel/guzzlestreams": "^3.0.1", + "php": ">=5.4.0", + "react/promise": "~2.0" + }, + "replace": { + "guzzlehttp/ringphp": "self.version" + }, + "require-dev": { + "ext-curl": "*", + "phpunit/phpunit": "~9.0" + }, + "suggest": { + "ext-curl": "Guzzle will use specific adapters if cURL is present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Ring\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Fork of guzzle/RingPHP (abandoned) to be used with elasticsearch-php", + "support": { + "source": "https://github.com/ezimuel/ringphp/tree/1.2.0" + }, + "time": "2021-11-16T11:51:30+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.2.0", @@ -2153,16 +2396,16 @@ }, { "name": "heseya/laravel-searchable", - "version": "1.0.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/heseya/laravel-searchable.git", - "reference": "301235f4e67784af659f7e81da19b674fc4ed525" + "reference": "6daaaf153fc9ffdb19bed8252b150126feac9f0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/heseya/laravel-searchable/zipball/301235f4e67784af659f7e81da19b674fc4ed525", - "reference": "301235f4e67784af659f7e81da19b674fc4ed525", + "url": "https://api.github.com/repos/heseya/laravel-searchable/zipball/6daaaf153fc9ffdb19bed8252b150126feac9f0f", + "reference": "6daaaf153fc9ffdb19bed8252b150126feac9f0f", "shasum": "" }, "require": { @@ -2170,7 +2413,7 @@ "php": ">=7.3" }, "require-dev": { - "pestphp/pest": "^0.2.4" + "pestphp/pest": "^1.21" }, "type": "library", "autoload": { @@ -2190,20 +2433,27 @@ "description": "Search trait for Eloquent models", "support": { "issues": "https://github.com/heseya/laravel-searchable/issues", - "source": "https://github.com/heseya/laravel-searchable/tree/master" + "source": "https://github.com/heseya/laravel-searchable/tree/2.0.0" }, - "time": "2020-08-14T08:39:11+00:00" + "time": "2022-03-14T10:36:47+00:00" }, { "name": "heseya/pagination", - "version": "1.0.0", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/heseya/laravel-pagination.git", + "reference": "ac83ba6801f213e3b61126ccf4cf66dd09c41a07" + }, "dist": { - "type": "path", - "url": "./heseya/pagination", - "reference": "b19285987b8964ee46414770f789e45f9749abbd" + "type": "zip", + "url": "https://api.github.com/repos/heseya/laravel-pagination/zipball/ac83ba6801f213e3b61126ccf4cf66dd09c41a07", + "reference": "ac83ba6801f213e3b61126ccf4cf66dd09c41a07", + "shasum": "" }, "require": { - "laravel/framework": "^9.0" + "laravel/framework": "^8.0|^9.0", + "php": "^8.0" }, "type": "library", "extra": { @@ -2215,20 +2465,25 @@ }, "autoload": { "psr-4": { - "Heseya\\Pagination\\": "src/", - "Heseya\\Pagination\\Tests\\": "tests/" + "Heseya\\Pagination\\": "src/" } }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], "authors": [ { "name": "Jędrzej Buliński", "email": "jedrzej@heseya.com" } ], - "description": "Heseya Store Pagination Middleware", - "transport-options": { - "relative": true - } + "description": "Heseya Laravel Pagination Middleware", + "support": { + "issues": "https://github.com/heseya/laravel-pagination/issues", + "source": "https://github.com/heseya/laravel-pagination/tree/1.0.2" + }, + "time": "2022-03-29T06:19:39+00:00" }, { "name": "heseya/resource", @@ -2255,31 +2510,6 @@ "relative": true } }, - { - "name": "heseya/sortable", - "version": "1.0.0", - "dist": { - "type": "path", - "url": "./heseya/sortable", - "reference": "4183c93f3b2c36c0f2b429c0d78ce31c4fbd984c" - }, - "require": { - "laravel/framework": "^9.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Heseya\\Sortable\\": "src/" - } - }, - "license": [ - "MIT" - ], - "description": "Sortable Trait for Laravel", - "transport-options": { - "relative": true - } - }, { "name": "http-interop/http-factory-guzzle", "version": "1.2.0", @@ -2712,6 +2942,78 @@ }, "time": "2022-03-29T14:41:26+00:00" }, + { + "name": "laravel/scout", + "version": "v9.4.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/scout.git", + "reference": "c4b697218ea7abe89894312caf86668b1f169233" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/scout/zipball/c4b697218ea7abe89894312caf86668b1f169233", + "reference": "c4b697218ea7abe89894312caf86668b1f169233", + "shasum": "" + }, + "require": { + "illuminate/bus": "^8.0|^9.0", + "illuminate/contracts": "^8.0|^9.0", + "illuminate/database": "^8.0|^9.0", + "illuminate/http": "^8.0|^9.0", + "illuminate/pagination": "^8.0|^9.0", + "illuminate/queue": "^8.0|^9.0", + "illuminate/support": "^8.0|^9.0", + "php": "^7.3|^8.0" + }, + "require-dev": { + "meilisearch/meilisearch-php": "^0.19", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^6.17|^7.0", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", + "meilisearch/meilisearch-php": "Required to use the MeiliSearch engine (^0.23)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.x-dev" + }, + "laravel": { + "providers": [ + "Laravel\\Scout\\ScoutServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Scout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.", + "keywords": [ + "algolia", + "laravel", + "search" + ], + "support": { + "issues": "https://github.com/laravel/scout/issues", + "source": "https://github.com/laravel/scout" + }, + "time": "2022-03-29T15:56:47+00:00" + }, { "name": "laravel/serializable-closure", "version": "v1.1.1", @@ -11344,16 +11646,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.5.3", + "version": "1.5.4", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "39953ac1452a8843702ee41a35b4861d3e8207a7" + "reference": "bbf68cae24f6dc023c607ea0f87da55dd9d55c2b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/39953ac1452a8843702ee41a35b4861d3e8207a7", - "reference": "39953ac1452a8843702ee41a35b4861d3e8207a7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/bbf68cae24f6dc023c607ea0f87da55dd9d55c2b", + "reference": "bbf68cae24f6dc023c607ea0f87da55dd9d55c2b", "shasum": "" }, "require": { @@ -11379,7 +11681,7 @@ "description": "PHPStan - PHP Static Analysis Tool", "support": { "issues": "https://github.com/phpstan/phpstan/issues", - "source": "https://github.com/phpstan/phpstan/tree/1.5.3" + "source": "https://github.com/phpstan/phpstan/tree/1.5.4" }, "funding": [ { @@ -11399,7 +11701,7 @@ "type": "tidelift" } ], - "time": "2022-03-30T21:55:08+00:00" + "time": "2022-04-03T12:39:00+00:00" }, { "name": "phpunit/php-code-coverage", @@ -12266,16 +12568,16 @@ }, { "name": "sebastian/environment", - "version": "5.1.3", + "version": "5.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "388b6ced16caa751030f6a69e588299fa09200ac" + "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac", - "reference": "388b6ced16caa751030f6a69e588299fa09200ac", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7", + "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7", "shasum": "" }, "require": { @@ -12317,7 +12619,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.3" + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.4" }, "funding": [ { @@ -12325,7 +12627,7 @@ "type": "github" } ], - "time": "2020-09-28T05:52:38+00:00" + "time": "2022-04-03T09:37:03+00:00" }, { "name": "sebastian/exporter", diff --git a/config/app.php b/config/app.php index b725d3944..0dd6a8f24 100644 --- a/config/app.php +++ b/config/app.php @@ -15,14 +15,6 @@ 'name' => env('APP_NAME', 'Heseya Store'), - /* - |-------------------------------------------------------------------------- - | Application Version - |-------------------------------------------------------------------------- - */ - - 'ver' => '2.0.5', - /* |-------------------------------------------------------------------------- | Application Environment @@ -180,6 +172,7 @@ * Application Service Providers... */ App\Providers\AppServiceProvider::class, + App\Providers\RepositoryServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, diff --git a/config/explorer.php b/config/explorer.php new file mode 100644 index 000000000..b3a9136e1 --- /dev/null +++ b/config/explorer.php @@ -0,0 +1,31 @@ + [ + 'host' => Env::get('ELASTICSEARCH_HOST', 'elasticsearch'), + ], + + /** + * An index may be defined on an Eloquent model or inline below. A more in depth explanation + * of the mapping possibilities can be found in the documentation of Explorer's repository. + */ + 'indexes' => [ + Product::class, + ], + + /** + * You may opt to keep the old indices after the alias is pointed to a new index. + * A model is only using index aliases if it implements the Aliased interface. + */ + 'prune_old_aliases' => true, +]; diff --git a/config/insights.php b/config/insights.php index 845b59218..c50a1e5d0 100644 --- a/config/insights.php +++ b/config/insights.php @@ -3,6 +3,9 @@ declare(strict_types=1); use Heseya\Insights\Sniffs\NotSpaceAfterNot; +use NunoMaduro\PhpInsights\Domain\Insights\Composer\ComposerMustBeValid; +use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenDefineFunctions; +use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenGlobals; use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenNormalClasses; use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenPrivateMethods; use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenTraits; @@ -13,6 +16,7 @@ use PHP_CodeSniffer\Standards\Generic\Sniffs\Files\LineEndingsSniff; use PHP_CodeSniffer\Standards\Generic\Sniffs\Files\LineLengthSniff; use PHP_CodeSniffer\Standards\Generic\Sniffs\Formatting\SpaceAfterNotSniff; +use SlevomatCodingStandard\Sniffs\Arrays\DisallowImplicitArrayCreationSniff; use PHP_CodeSniffer\Standards\Generic\Sniffs\PHP\ForbiddenFunctionsSniff; use PhpCsFixer\Fixer\FunctionNotation\VoidReturnFixer; use SlevomatCodingStandard\Sniffs\Classes\ForbiddenPublicPropertySniff; @@ -81,6 +85,8 @@ // replaced with own SpaceAfterNotSniff::class, DisallowShortTernaryOperatorSniff::class, + ForbiddenGlobals::class, + DisallowImplicitArrayCreationSniff::class, ], 'config' => [ diff --git a/config/pagination.php b/config/pagination.php new file mode 100644 index 000000000..eb2fcfec9 --- /dev/null +++ b/config/pagination.php @@ -0,0 +1,39 @@ + Env::get('PAGINATION_LIMIT_KEY', 'limit'), + + /* + |-------------------------------------------------------------------------- + | Pagination default + |-------------------------------------------------------------------------- + | + | Default pagination limit, when no pagination was found in request. + | + */ + 'per_page' => (int) Env::get('PAGINATION_DEFAULT', 24), + + /* + |-------------------------------------------------------------------------- + | Pagination max + |-------------------------------------------------------------------------- + | + | Max pagination limit. + | + */ + 'max' => (int) Env::get('PAGINATION_MAX', 500), + +]; diff --git a/config/scout.php b/config/scout.php new file mode 100644 index 000000000..429e9975c --- /dev/null +++ b/config/scout.php @@ -0,0 +1,101 @@ + env('SCOUT_DRIVER', 'elastic'), + + /* + |-------------------------------------------------------------------------- + | Index Prefix + |-------------------------------------------------------------------------- + | + | Here you may specify a prefix that will be applied to all search index + | names used by Scout. This prefix may be useful if you have multiple + | "tenants" or applications sharing the same search infrastructure. + | + */ + + 'prefix' => env('SCOUT_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Queue Data Syncing + |-------------------------------------------------------------------------- + | + | This option allows you to control if the operations that sync your data + | with your search engines are queued. When this is set to "true" then + | all automatic data syncing will get queued for better performance. + | + */ + + 'queue' => env('SCOUT_QUEUE', false), + + /* + |-------------------------------------------------------------------------- + | Database Transactions + |-------------------------------------------------------------------------- + | + | This configuration option determines if your data will only be synced + | with your search indexes after every open database transaction has + | been committed, thus preventing any discarded data from syncing. + | + */ + + 'after_commit' => false, + + /* + |-------------------------------------------------------------------------- + | Chunk Sizes + |-------------------------------------------------------------------------- + | + | These options allow you to control the maximum chunk size when you are + | mass importing data into the search engine. This allows you to fine + | tune each of these chunk sizes based on the power of the servers. + | + */ + + 'chunk' => [ + 'searchable' => 500, + 'unsearchable' => 500, + ], + + /* + |-------------------------------------------------------------------------- + | Soft Deletes + |-------------------------------------------------------------------------- + | + | This option allows to control whether to keep soft deleted records in + | the search indexes. Maintaining soft deleted records can be useful + | if your application still needs to search for the records later. + | + */ + + 'soft_delete' => false, + + /* + |-------------------------------------------------------------------------- + | Identify User + |-------------------------------------------------------------------------- + | + | This option allows you to control whether to notify the search engine + | of the user performing the search. This is sometimes useful if the + | engine supports any analytics based on this application's users. + | + | Supported engines: "algolia" + | + */ + + 'identify' => env('SCOUT_IDENTIFY', false), + +]; diff --git a/config/search.php b/config/search.php new file mode 100644 index 000000000..94616c5a0 --- /dev/null +++ b/config/search.php @@ -0,0 +1,84 @@ + [ + 'stopwords' => [ + 'a', + 'aby', + 'ach', + 'acz', + 'aczkolwiek', + 'aj', + 'albo', + 'ale', + 'ależ', + 'ani', + 'aż', + 'bardziej', + 'bardzo', + 'bo', + 'bowiem', + 'by', + 'byli', + 'bynajmniej', + 'być', + 'był', + 'była', + 'było', + 'były', + 'będzie', + 'będą', + 'cali', + 'cała', + 'cały', + 'ci', + 'cię', + 'ciebie', + 'co', + 'cokolwiek', + 'coś', + 'czasami', + 'czasem', + 'czemu', + 'czy', + 'czyli', + 'daleko', + 'dla', + 'dlaczego', + 'dlatego', + 'do', + 'dobrze', + 'dokąd', + 'dość', + 'dużo', + 'dwa', + 'dwaj', + 'dwie', + 'dwoje', + 'dziś', + 'dzisiaj', + 'gdy', + 'gdyby', + 'gdyż', + 'gdzie', + 'gdziekolwiek', + 'gdzieś', + 'go', + 'i', + 'ich', + 'ile', + 'im', + 'inna', + 'inne', + 'inny', + 'innych', + 'iż', + 'ja', + 'ją', + 'jak', + 'jakaś', + ], + ], + +]; diff --git a/database/factories/AttributeFactory.php b/database/factories/AttributeFactory.php new file mode 100644 index 000000000..46d70c831 --- /dev/null +++ b/database/factories/AttributeFactory.php @@ -0,0 +1,37 @@ +faker->unique()->word; + + return [ + 'name' => $name, + 'slug' => Str::slug($name), + 'description' => $this->faker->sentence, + 'type' => AttributeType::getRandomValue(), + 'global' => $this->faker->boolean, + 'sortable' => $this->faker->boolean, + ]; + } +} diff --git a/database/factories/AttributeOptionFactory.php b/database/factories/AttributeOptionFactory.php new file mode 100644 index 000000000..74c24d0d8 --- /dev/null +++ b/database/factories/AttributeOptionFactory.php @@ -0,0 +1,30 @@ + $this->faker->word, + 'value_number' => rand(0, 1) === 1 ? $this->faker->randomNumber(5) : null, + 'value_date' => $this->faker->date, + ]; + } +} diff --git a/database/factories/MetadataFactory.php b/database/factories/MetadataFactory.php new file mode 100644 index 000000000..054f340fa --- /dev/null +++ b/database/factories/MetadataFactory.php @@ -0,0 +1,30 @@ + $this->faker->word, + 'value' => $this->faker->word, + 'value_type' => MetadataType::getRandomValue(), + 'public' => $this->faker->boolean, + ]; + } +} diff --git a/database/factories/RoleFactory.php b/database/factories/RoleFactory.php index 4e1b8af59..e6ce8d24f 100644 --- a/database/factories/RoleFactory.php +++ b/database/factories/RoleFactory.php @@ -20,7 +20,7 @@ class RoleFactory extends Factory public function definition(): array { return [ - 'name' => $this->faker->word, + 'name' => $this->faker->word . '-' . rand(1, 99999), 'description' => $this->faker->sentence(10), ]; } diff --git a/database/migrations/2021_11_26_094412_precalculate_product_visibility.php b/database/migrations/2021_11_26_094412_precalculate_product_visibility.php index 4c716fa4f..29917175f 100644 --- a/database/migrations/2021_11_26_094412_precalculate_product_visibility.php +++ b/database/migrations/2021_11_26_094412_precalculate_product_visibility.php @@ -1,6 +1,7 @@ boolean('public_legacy')->nullable(); // Deprecated, to be removed in 3.0 }); - Product::chunk(100, fn ($products) => $products->each( + Product::chunk(100, fn (Collection $products) => $products->each( function (Product $product) { $isAnySetPublic = $product->sets->count() === 0 || $product->sets->where('public', true)->where('public_parent', true); $newPublic = $product->public && $isAnySetPublic; - $product->update([ - 'public_legacy' => $product->public, - 'public' => $newPublic, - ]); + Product::withoutSyncingToSearch(function () use ($product, $newPublic): void { + $product->update([ + 'public_legacy' => $product->public, + 'public' => $newPublic, + ]); + }); }, )); } @@ -40,7 +43,7 @@ function (Product $product) { */ public function down() { - Product::chunk(100, fn ($products) => $products->each( + Product::chunk(100, fn (Collection $products) => $products->each( fn (Product $product) => $product->update([ 'public' => $product->public_legacy, ]), diff --git a/database/migrations/2022_02_09_133640_create_attributes_table.php b/database/migrations/2022_02_09_133640_create_attributes_table.php new file mode 100644 index 000000000..a40d0d134 --- /dev/null +++ b/database/migrations/2022_02_09_133640_create_attributes_table.php @@ -0,0 +1,41 @@ +uuid('id')->primary(); + $table->string('name'); + $table->string('slug')->unique()->index(); + $table->string('description')->nullable(); + $table->float('min_number')->nullable(); + $table->float('max_number')->nullable(); + $table->date('min_date')->nullable(); + $table->date('max_date')->nullable(); + $table->string('type'); + $table->boolean('global'); + $table->boolean('sortable'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('attributes'); + } +} diff --git a/database/migrations/2022_02_09_133727_create_attribute_options_table.php b/database/migrations/2022_02_09_133727_create_attribute_options_table.php new file mode 100644 index 000000000..06d23f81e --- /dev/null +++ b/database/migrations/2022_02_09_133727_create_attribute_options_table.php @@ -0,0 +1,37 @@ +uuid('id')->primary(); + $table->string('name')->nullable(); + $table->integer('index'); + $table->float('value_number')->nullable()->default(null); + $table->date('value_date')->nullable()->default(null); + $table->foreignUuid('attribute_id')->references('id')->on('attributes')->onDelete('cascade')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('attribute_options'); + } +} diff --git a/database/migrations/2022_02_10_135355_attribute_permissions.php b/database/migrations/2022_02_10_135355_attribute_permissions.php new file mode 100644 index 000000000..d5e5d7c22 --- /dev/null +++ b/database/migrations/2022_02_10_135355_attribute_permissions.php @@ -0,0 +1,71 @@ + 'attributes.show', 'display_name' => 'Dostęp do listy cech']); + Permission::create(['name' => 'attributes.add', 'display_name' => 'Możliwość tworzenia cech']); + Permission::create([ + 'name' => 'attributes.edit', + 'display_name' => 'Możliwość edycji cech oraz modyfikacji ich opcji', + 'description' => 'Pozwala również na dodawanie nowych i usuwanie opcji cech' + ]); + Permission::create(['name' => 'attributes.remove', 'display_name' => 'Możliwość usuwania cech']); + + $owner = Role::query()->where('type', '=', RoleType::OWNER)->firstOrFail(); + $owner->givePermissionTo([ + 'attributes.show', + 'attributes.add', + 'attributes.edit', + 'attributes.remove', + ]); + $owner->save(); + + $authenticated = Role::query()->where('type', '=', RoleType::AUTHENTICATED)->firstOrFail(); + $authenticated->givePermissionTo([ + 'attributes.show', + ]); + $authenticated->save(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $owner = Role::query()->where('type', '=', RoleType::OWNER)->firstOrFail(); + $owner->revokePermissionTo([ + 'attributes.show', + 'attributes.add', + 'attributes.edit', + 'attributes.remove', + ]); + $owner->save(); + + $authenticated = Role::query()->where('type', '=', RoleType::AUTHENTICATED)->firstOrFail(); + $authenticated->revokePermissionTo([ + 'attributes.show', + ]); + $authenticated->save(); + + Permission::findByName('attributes.show')->delete(); + Permission::findByName('attributes.add')->delete(); + Permission::findByName('attributes.edit')->delete(); + Permission::findByName('attributes.remove')->delete(); + } +} diff --git a/database/migrations/2022_02_15_113642_product_attribute.php b/database/migrations/2022_02_15_113642_product_attribute.php new file mode 100644 index 000000000..27edd2f42 --- /dev/null +++ b/database/migrations/2022_02_15_113642_product_attribute.php @@ -0,0 +1,48 @@ +foreignUuid('product_id') + ->index() + ->references('id') + ->on('products') + ->onDelete('cascade'); + + $table->foreignUuid('attribute_id') + ->index() + ->references('id') + ->on('attributes') + ->onDelete('cascade'); + + $table->foreignUuid('option_id') + ->index() + ->references('id') + ->on('attribute_options') + ->onDelete('cascade'); + + $table->primary(['product_id', 'attribute_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('product_attribute'); + } +} diff --git a/database/migrations/2022_02_22_090707_create_attribute_product_set_table.php b/database/migrations/2022_02_22_090707_create_attribute_product_set_table.php new file mode 100644 index 000000000..ad67ad5b3 --- /dev/null +++ b/database/migrations/2022_02_22_090707_create_attribute_product_set_table.php @@ -0,0 +1,31 @@ +uuid('attribute_id')->index(); + $table->uuid('product_set_id')->index(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('attribute_product_set'); + } +} diff --git a/database/migrations/2022_03_01_073057_add_available_column_to_options_schemas_and_products.php b/database/migrations/2022_03_01_073057_add_available_column_to_options_schemas_and_products.php index ff667aa87..cc8ebb8c4 100644 --- a/database/migrations/2022_03_01_073057_add_available_column_to_options_schemas_and_products.php +++ b/database/migrations/2022_03_01_073057_add_available_column_to_options_schemas_and_products.php @@ -34,7 +34,11 @@ public function up() $items->each(fn ($item) => $availabilityService->calculateAvailabilityOnOrderAndRestock($item)); $products = Product::doesntHave('schemas')->get(); - $products->each(fn ($product) => $product->update(['available' => true])); + $products->each(function (Product $product): void { + Product::withoutSyncingToSearch(function () use ($product): void { + $product->update(['available' => true]); + }); + }); } /** diff --git a/database/migrations/2022_03_03_095726_create_metadata_table.php b/database/migrations/2022_03_03_095726_create_metadata_table.php new file mode 100644 index 000000000..605708ab8 --- /dev/null +++ b/database/migrations/2022_03_03_095726_create_metadata_table.php @@ -0,0 +1,36 @@ +uuid('id')->primary(); + $table->string('name'); + $table->string('value'); + $table->string('value_type'); + $table->uuidMorphs('model'); + $table->boolean('public')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('metadata'); + } +} diff --git a/database/migrations/2022_03_03_103126_add_metadata_permissions.php b/database/migrations/2022_03_03_103126_add_metadata_permissions.php new file mode 100644 index 000000000..79c3d6d67 --- /dev/null +++ b/database/migrations/2022_03_03_103126_add_metadata_permissions.php @@ -0,0 +1,48 @@ + 'orders.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych zamówień']); + Permission::create(['name' => 'pages.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych stron']); + Permission::create(['name' => 'products.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych produktów']); + Permission::create(['name' => 'users.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych użytkowników']); + + $owner = Role::query()->where('type', '=', RoleType::OWNER)->firstOrFail(); + $owner->givePermissionTo([ + 'orders.show_metadata_private', + 'pages.show_metadata_private', + 'products.show_metadata_private', + 'users.show_metadata_private', + ]); + $owner->save(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $owner = Role::query()->where('type', '=', RoleType::OWNER)->firstOrFail(); + $owner->revokePermissionTo([ + 'orders.show_metadata_private', + 'pages.show_metadata_private', + 'products.show_metadata_private', + 'users.show_metadata_private', + ]); + $owner->save(); + } +} diff --git a/database/migrations/2022_03_17_160126_add_more_metadata_permissions.php b/database/migrations/2022_03_17_160126_add_more_metadata_permissions.php new file mode 100644 index 000000000..53efe7706 --- /dev/null +++ b/database/migrations/2022_03_17_160126_add_more_metadata_permissions.php @@ -0,0 +1,77 @@ + 'schemas.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych schematów']); + Permission::create(['name' => 'options.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych stron']); + Permission::create(['name' => 'product_sets.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych zestawów produktów']); + Permission::create(['name' => 'discounts.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych kuponów']); + Permission::create(['name' => 'items.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych przedmiotów']); + Permission::create(['name' => 'statuses.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych statusów']); + Permission::create(['name' => 'shipping_methods.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych metod dostawy']); + Permission::create(['name' => 'packages.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych szablonów paczek']); + Permission::create(['name' => 'roles.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych ról']); + Permission::create(['name' => 'apps.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych aplikacji']); + Permission::create(['name' => 'media.show_metadata_private', 'display_name' => 'Możliwość wyświetlania prywatnych metadanych mediów']); + + $owner = Role::query()->where('type', '=', RoleType::OWNER)->firstOrFail(); + $owner->givePermissionTo([ + 'schemas.show_metadata_private', + 'options.show_metadata_private', + 'product_sets.show_metadata_private', + 'discounts.show_metadata_private', + 'items.show_metadata_private', + 'statuses.show_metadata_private', + 'shipping_methods.show_metadata_private', + 'packages.show_metadata_private', + 'roles.show_metadata_private', + 'apps.show_metadata_private', + 'media.show_metadata_private', + ]); + $owner->save(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $permissions = [ + 'schemas.show_metadata_private', + 'options.show_metadata_private', + 'product_sets.show_metadata_private', + 'discounts.show_metadata_private', + 'items.show_metadata_private', + 'statuses.show_metadata_private', + 'shipping_methods.show_metadata_private', + 'packages.show_metadata_private', + 'roles.show_metadata_private', + 'apps.show_metadata_private', + 'media.show_metadata_private', + ]; + + $owner = Role::query()->where('type', '=', RoleType::OWNER)->firstOrFail(); + $owner->revokePermissionTo($permissions); + $owner->save(); + + foreach ($permissions as $permission) { + Permission::query() + ->where('name', '=', $permission) + ->delete(); + } + } +} diff --git a/database/migrations/2022_03_24_092349_create_product_attribute_attribute_option_table.php b/database/migrations/2022_03_24_092349_create_product_attribute_attribute_option_table.php new file mode 100644 index 000000000..59a4184e0 --- /dev/null +++ b/database/migrations/2022_03_24_092349_create_product_attribute_attribute_option_table.php @@ -0,0 +1,56 @@ +dropForeign('product_attribute_option_id_foreign'); + + $table->dropColumn('option_id'); + $table->dropPrimary(['product_id', 'attribute_id']); + $table->unique(['product_id', 'attribute_id']); + $table->uuid('id')->primary(); + }); + + Schema::create('product_attribute_attribute_option', function (Blueprint $table) { + $table->uuid('product_attribute_id'); + $table->uuid('attribute_option_id'); + + $table->foreign('product_attribute_id')->references('id')->on('product_attribute')->onDelete('cascade'); + $table->foreign('attribute_option_id')->references('id')->on('attribute_options')->onDelete('cascade'); + + $table->primary(['product_attribute_id', 'attribute_option_id'], 'product_attribute_attribute_option_primary'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('product_attribute_attribute_option'); + + Schema::table('product_attribute', function (Blueprint $table) { + $table->dropPrimary('product_attribute_id_primary'); + $table->dropColumn('id'); + + $table->dropUnique(['product_id', 'attribute_id']); + $table->primary(['product_id', 'attribute_id']); + + $table->uuid('option_id'); + $table->foreign('option_id')->references('id')->on('attribute_options')->onDelete('cascade'); + }); + } +} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php index 45ecaf507..627740b34 100644 --- a/database/seeders/ProductSeeder.php +++ b/database/seeders/ProductSeeder.php @@ -2,12 +2,12 @@ namespace Database\Seeders; -use App\Models\ProductSet; use App\Models\Deposit; use App\Models\Item; use App\Models\Media; use App\Models\Option; use App\Models\Product; +use App\Models\ProductSet; use App\Models\Schema; use App\Models\SeoMetadata; use App\Services\Contracts\ProductServiceContract; @@ -34,7 +34,7 @@ public function run() $brands = ProductSet::factory([ 'name' => 'Brands', 'slug' => 'brands', - ])->create(); + ])->make(); $this->seo($brands); $brands = ProductSet::factory([ 'parent_id' => $brands->getKey(), @@ -71,10 +71,18 @@ public function run() $this->categories($product, $categories); } + $product->refresh(); + $product->save(); $productService->updateMinMaxPrices($product); }); } + private function seo(Product|ProductSet $product): void + { + $seo = SeoMetadata::factory()->create(); + $product->seo()->save($seo); + } + private function schemas(Product $product): void { $schema = Schema::factory()->make(); @@ -101,19 +109,13 @@ private function sets(Product $product, Collection $sets): void } } - private function categories(Product $product, Collection $categories): void - { - $product->sets()->syncWithoutDetaching($categories->random()); - } - private function brands(Product $product, Collection $brands): void { $product->sets()->syncWithoutDetaching($brands->random()); } - private function seo(Product|ProductSet $product): void + private function categories(Product $product, Collection $categories): void { - $seo = SeoMetadata::factory()->create(); - $product->seo()->save($seo); + $product->sets()->syncWithoutDetaching($categories->random()); } } diff --git a/docker-compose.yaml b/docker-compose.yaml index dd554987f..e8fa1469c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,6 +20,9 @@ services: touch /usr/src/init fi exec apache2-foreground + depends_on: + - mysql_service + - elasticsearch mysql_service: image: mariadb:10.5 restart: unless-stopped @@ -27,27 +30,46 @@ services: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} MYSQL_DATABASE: ${DB_DATABASE} ports: - - ${DB_PORT}:3306 + - ${DB_PORT:-3306}:3306 adminer: image: adminer restart: unless-stopped environment: - ADMINER_DEFAULT_SERVER=mysql_service redis: - image: redis:6.2 + image: redis:5.0 restart: unless-stopped ports: - - ${REDIS_PORT}:6379 + - ${REDIS_PORT:-6379}:6379 + elasticsearch: + build: + context: ./docker + dockerfile: Dockerfile-elastic + ports: + - ${ELASTICSEARCH_PORT:-9200}:9200 + environment: + - xpack.security.enabled=false + - discovery.type=single-node + kibana: + image: docker.elastic.co/kibana/kibana:8.1.0 + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:${ELASTICSEARCH_PORT:-9200} + depends_on: + - elasticsearch nginx: image: nginx restart: unless-stopped volumes: - ./docker/nginx:/etc/nginx/templates ports: - - ${DOCKER_PORT}:80 + - ${DOCKER_PORT:-80}:80 environment: - - ADMINER_PREFIX=${DOCKER_ADMINER_PREFIX} - - SILVERBOX_PREFIX=${DOCKER_SILVERBOX_PREFIX} + - ADMINER_PREFIX=${DOCKER_ADMINER_PREFIX:-adminer} + - KIBANA_PREFIX=${DOCKER_KIBANA_PREFIX:-kibana} + - SILVERBOX_PREFIX=${DOCKER_SILVERBOX_PREFIX:-silverbox} + depends_on: + - app + - kibana silverbox: image: heseya/silverbox:1.2.0 restart: unless-stopped diff --git a/docker/Dockerfile-elastic b/docker/Dockerfile-elastic new file mode 100644 index 000000000..3e950888a --- /dev/null +++ b/docker/Dockerfile-elastic @@ -0,0 +1,3 @@ +FROM docker.elastic.co/elasticsearch/elasticsearch:8.1.0 + +RUN bin/elasticsearch-plugin install pl.allegro.tech.elasticsearch.plugin:elasticsearch-analysis-morfologik:8.1.0 diff --git a/docker/nginx/default.conf.template b/docker/nginx/default.conf.template index df0573fba..760a21bff 100644 --- a/docker/nginx/default.conf.template +++ b/docker/nginx/default.conf.template @@ -33,3 +33,16 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } + + +server { + listen 80; + server_name ${KIBANA_PREFIX}.*; + + location / { + proxy_pass http://kibana:5601; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/heseya/pagination/.gitignore b/heseya/pagination/.gitignore deleted file mode 100644 index 987e2a253..000000000 --- a/heseya/pagination/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -composer.lock -vendor diff --git a/heseya/pagination/composer.json b/heseya/pagination/composer.json deleted file mode 100644 index a8deddbff..000000000 --- a/heseya/pagination/composer.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "heseya/pagination", - "description": "Heseya Store Pagination Middleware", - "type": "library", - "version": "1.0.0", - "require": { - "laravel/framework": "^9.0" - }, - "autoload": { - "psr-4": { - "Heseya\\Pagination\\": "src/", - "Heseya\\Pagination\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Heseya\\Pagination\\ServiceProvider" - ] - } - }, - "authors": [ - { - "name": "Jędrzej Buliński", - "email": "jedrzej@heseya.com" - } - ] -} diff --git a/heseya/pagination/config/pagination.php b/heseya/pagination/config/pagination.php deleted file mode 100644 index def7b28d5..000000000 --- a/heseya/pagination/config/pagination.php +++ /dev/null @@ -1,9 +0,0 @@ - (int) env('PAGINATION_DEFAULT', 24), - - 'max' => (int) env('PAGINATION_MAX', 500), - -]; diff --git a/heseya/pagination/src/Http/Middleware/Pagination.php b/heseya/pagination/src/Http/Middleware/Pagination.php deleted file mode 100644 index f5e65cc82..000000000 --- a/heseya/pagination/src/Http/Middleware/Pagination.php +++ /dev/null @@ -1,34 +0,0 @@ -exists(self::LIMIT_NAME)) { - $validator = Validator::make($request->only(self::LIMIT_NAME), [ - self::LIMIT_NAME => ['integer', 'min:1', 'max:' . Config::get('pagination.max')], - ]); - - if ($validator->fails()) { - throw new StoreException($validator->errors()->first()); - } - - Config::set('pagination.per_page', $request->input(self::LIMIT_NAME)); - } - - return $next($request); - } -} diff --git a/heseya/pagination/src/ServiceProvider.php b/heseya/pagination/src/ServiceProvider.php deleted file mode 100644 index 5f2ab5282..000000000 --- a/heseya/pagination/src/ServiceProvider.php +++ /dev/null @@ -1,14 +0,0 @@ -mergeConfigFrom( - __DIR__ . '/../config/pagination.php', - 'pagination', - ); - } -} diff --git a/heseya/pagination/tests/Unit/MiddlewareTest.php b/heseya/pagination/tests/Unit/MiddlewareTest.php deleted file mode 100644 index c04c342ba..000000000 --- a/heseya/pagination/tests/Unit/MiddlewareTest.php +++ /dev/null @@ -1,78 +0,0 @@ -handle($request, function (): void { - }); - - $this->assertEquals(Config::get('pagination.per_page'), 100); - } - - public function testLimit(): void - { - $request = Request::create('/admin?limit=50', 'GET'); - - $middleware = new Pagination(); - $middleware->handle($request, function (): void { - }); - - $this->assertEquals(Config::get('pagination.per_page'), 50); - } - - public function testLimitValidation(): void - { - $request = Request::create('/admin?limit=TEST', 'GET'); - - $middleware = new Pagination(); - - $this->expectException(StoreException::class); - $middleware->handle($request, function (): void { - }); - } - - public function testLimitMax(): void - { - $request = Request::create('/admin?limit=1000', 'GET'); - - $middleware = new Pagination(); - - $this->expectException(StoreException::class); - $middleware->handle($request, function (): void { - }); - } - - public function testLimitMin(): void - { - $request = Request::create('/admin?limit=-1', 'GET'); - - $middleware = new Pagination(); - - $this->expectException(StoreException::class); - $middleware->handle($request, function (): void { - }); - } -} diff --git a/heseya/sortable/.gitignore b/heseya/sortable/.gitignore deleted file mode 100644 index 8b7ef3503..000000000 --- a/heseya/sortable/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/vendor -composer.lock diff --git a/heseya/sortable/composer.json b/heseya/sortable/composer.json deleted file mode 100644 index af65f7152..000000000 --- a/heseya/sortable/composer.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "heseya/sortable", - "description": "Sortable Trait for Laravel", - "type": "library", - "license": "MIT", - "version": "1.0.0", - "autoload": { - "psr-4": { - "Heseya\\Sortable\\": "src/" - } - }, - "require": { - "laravel/framework": "^9.0" - } -} diff --git a/heseya/sortable/src/Sortable.php b/heseya/sortable/src/Sortable.php deleted file mode 100644 index cefb5214a..000000000 --- a/heseya/sortable/src/Sortable.php +++ /dev/null @@ -1,57 +0,0 @@ - ['required', 'in:' . implode(',', $this->getSortable())], - '1' => ['in:asc,desc'], - ], - [ - 'required' => 'You must specify sort field.', - '0.in' => 'You can\'t sort by ' . $field . ' field.', - '1.in' => 'Only asc|desc sorting directions are allowed on field ' . $field . '.', - ] - )->validate(); - - $order = count($option) > 1 ? $option[1] : 'asc'; - $query->orderBy($field, $order); - } - } - - return $query->orderBy( - $this->getDefaultSortBy(), - $this->getDefaultSortDirection(), - ); - } - - private function getSortable(): array - { - return $this->sortable ?? []; - } - - private function getDefaultSortBy(): string - { - return $this->defaultSortBy ?? 'created_at'; - } - - private function getDefaultSortDirection(): string - { - return $this->defaultSortDirection ?? 'asc'; - } -} diff --git a/phpunit.xml b/phpunit.xml index 65473a2ab..f1bd82c06 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -24,9 +24,6 @@ ./tests/Feature - - ./heseya/pagination/tests/Unit - diff --git a/public/docs/api.yml b/public/docs/api.yml index 43f39a8d7..cf9e80a93 100644 --- a/public/docs/api.yml +++ b/public/docs/api.yml @@ -176,6 +176,8 @@ paths: items: $ref: '#/components/schemas/Event' type: object + /filters: + $ref: './paths/Filters.yml#/Filters' /furgonetka/create-package: $ref: './paths/Furgonetka.yml#/FurgonetkaCreatePackage' /items: @@ -650,6 +652,82 @@ paths: $ref: './paths/Users.yml#/Users' '/users/id:{id}': $ref: './paths/Users.yml#/UsersParams' + + '/products/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/products/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/schemas/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/schemas/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/options/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/options/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/product-sets/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/product-sets/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/discounts/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/discounts/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/items/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/items/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/orders/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/orders/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/statuses/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/statuses/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/shipping-methods/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/shipping-methods/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/package-templates/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/package-templates/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/users/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/users/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/roles/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/roles/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/pages/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/pages/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/apps/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/apps/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + + '/media/id:{id}/metadata': + $ref: './paths/Metadata.yml#Metadata' + '/media/id:{id}/metadata-private': + $ref: './paths/Metadata.yml#Metadata' + '/users/id:{id}/2fa/remove': $ref: './paths/Users.yml#/UserTFARemove' /webhooks: @@ -769,6 +847,14 @@ paths: type: object security: - oauth: [ ] + '/attributes': + $ref: './paths/Attributes.yml#Attributes' + '/attributes/id:{id}': + $ref: './paths/Attributes.yml#AttributesParams' + '/attributes/id:{id}/options': + $ref: './paths/Attributes.yml#AttributeOption' + '/attributes/id:{attribute_id}/options/id:{option_id}': + $ref: './paths/Attributes.yml#AttributeOptionParams' components: schemas: Error: @@ -1117,6 +1203,10 @@ components: $ref: './schemas/Seo.yml#SeoView' SeoKeywordsResponse: $ref: './schemas/Seo.yml#SeoKeywordsResponse' + Attribute: + $ref: './schemas/Attributes.yml#Attribute' + AttributeOption: + $ref: './schemas/Attributes.yml#AttributeOption' requestBodies: AppStore: $ref: './requests/Apps.yml#/AppStore' diff --git a/public/docs/paths/Attributes.yml b/public/docs/paths/Attributes.yml new file mode 100644 index 000000000..24e85e896 --- /dev/null +++ b/public/docs/paths/Attributes.yml @@ -0,0 +1,164 @@ +Attributes: + get: + tags: + - Attributes + summary: 'list attributes' + responses: + 200: + description: Success + content: + application/json: + schema: + properties: + data: + type: array + items: + $ref: './../schemas/Attributes.yml#/Attribute' + type: object + security: + - oauth: [ ] + post: + tags: + - Attributes + summary: 'add new attribute' + requestBody: + content: + application/json: + schema: + $ref: './../schemas/Attributes.yml#/Attribute' + responses: + 201: + description: Created + content: + application/json: + schema: + properties: + data: + $ref: './../schemas/Attributes.yml#/Attribute' + type: object + security: + - oauth: [ ] + +AttributesParams: + get: + tags: + - Attributes + summary: 'get attribute' + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + 200: + description: Success + content: + application/json: + schema: + properties: + data: + $ref: './../schemas/Attributes.yml#/Attribute' + type: object + security: + - oauth: [ ] + delete: + tags: + - Attributes + summary: 'delete attribute' + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + 204: + description: Success + security: + - oauth: [ ] + patch: + tags: + - Attributes + summary: 'update attribute' + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: './../schemas/Attributes.yml#/Attribute' + responses: + 200: + description: Success + content: + application/json: + schema: + properties: + data: + $ref: './../schemas/Attributes.yml#/Attribute' + type: object + security: + - oauth: [ ] + +AttributeOption: + post: + tags: + - Attributes + summary: 'add new option to attribute' + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: './../schemas/Attributes.yml#/AttributeOption' +AttributeOptionParams: + patch: + tags: + - Attributes + summary: 'update option' + parameters: + - name: attribute_id + in: path + required: true + schema: + type: integer + - name: option_id + in: path + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: './../schemas/Attributes.yml#/AttributeOption' + delete: + tags: + - Attributes + summary: 'delete option' + parameters: + - name: attribute_id + in: path + required: true + schema: + type: integer + - name: option_id + in: path + required: true + schema: + type: integer + responses: + 204: + description: Success + security: + - oauth: [ ] diff --git a/public/docs/paths/Filters.yml b/public/docs/paths/Filters.yml new file mode 100644 index 000000000..6f58d3054 --- /dev/null +++ b/public/docs/paths/Filters.yml @@ -0,0 +1,14 @@ +Filters: + get: + tags: + - Filters + summary: 'gets sets filters' + parameters: + - name: 'sets[]' + in: query + description: 'Array of sets ids to search attributes' + schema: + type: string + example: 'sets[]=026bc5f6-8373-4aeb-972e-e78d72a67121&sets[]=026bc5f6-8373-4aeb-972e-e78d72a67121' + security: + - oauth: [ ] diff --git a/public/docs/paths/Metadata.yml b/public/docs/paths/Metadata.yml new file mode 100644 index 000000000..5665cd6ed --- /dev/null +++ b/public/docs/paths/Metadata.yml @@ -0,0 +1,17 @@ +Metadata: + patch: + tags: + - Metadata + summary: 'Create/update/delete metadata' + requestBody: + $ref: './../requests/Metadata.yml#Metadata' + responses: + 200: + description: 'Returned list has whole metadata attached to requested object' + content: + application/json: + schema: + properties: + data: + $ref: './../schemas/Metadata.yml#Metadata' + type: object diff --git a/public/docs/requests/Metadata.yml b/public/docs/requests/Metadata.yml new file mode 100644 index 000000000..fa72e7e21 --- /dev/null +++ b/public/docs/requests/Metadata.yml @@ -0,0 +1,9 @@ +Metadata: + content: + application/json: + schema: + type: object + properties: + faktura: + type: string + example: 'FV/2022/03/10/005' diff --git a/public/docs/requests/ProductSets.yml b/public/docs/requests/ProductSets.yml index a9ebdcc8d..f080c1bd6 100644 --- a/public/docs/requests/ProductSets.yml +++ b/public/docs/requests/ProductSets.yml @@ -46,6 +46,12 @@ ProductSetStore: example: 026bc5f6-8373-4aeb-972e-e78d72a67121 seo: $ref: './../schemas/Seo.yml#SeoStore' + attributes: + description: 'Array of assigned attributes ids' + type: array + items: + type: string + example: 026bc5f6-8373-4aeb-972e-e78d72a67121 type: object ProductSetUpdate: content: @@ -95,6 +101,12 @@ ProductSetUpdate: example: 026bc5f6-8373-4aeb-972e-e78d72a67121 seo: $ref: './../schemas/Seo.yml#SeoStore' + attributes: + description: 'Array of assigned attributes ids' + type: array + items: + type: string + example: 026bc5f6-8373-4aeb-972e-e78d72a67121 type: object ProductSetAttach: content: diff --git a/public/docs/requests/Products.yml b/public/docs/requests/Products.yml index 0ca6d044d..25ab1d188 100644 --- a/public/docs/requests/Products.yml +++ b/public/docs/requests/Products.yml @@ -38,6 +38,10 @@ Product: items: type: string example: 0006c3a0-21af-4485-b7fe-9c42233cf03a + metadata: + $ref: './../schemas/Metadata.yml#Metadata' + metadata_private: + $ref: './../schemas/Metadata.yml#Metadata' ProductStore: content: @@ -53,6 +57,12 @@ ProductStore: properties: description_short: type: string + attributes: + type: array + items: + type: string + description: attribute_id,option_id + example: 0006c3a0-21af-4485-b7fe-9c42233cf03a,0006c654-21bn-44rt-b7fe-9c4uyji8f03a type: object ProductUpdate: diff --git a/public/docs/schemas/Attributes.yml b/public/docs/schemas/Attributes.yml new file mode 100644 index 000000000..f1257593e --- /dev/null +++ b/public/docs/schemas/Attributes.yml @@ -0,0 +1,118 @@ +Attribute: + type: object + properties: + id: + type: string + example: 026bc5f6-8373-4aeb-972e-e78d72a67121 + name: + description: 'Name of attribute' + type: string + example: 'Screen size' + slug: + description: 'Slug of name attribute' + type: string + example: 'screen-size' + description: + description: 'Description of attribute' + type: string + example: 'Presented number is size of screen monitor' + min: + description: 'Lowest value from options' + type: integer + example: '22' + max: + description: 'Highest value from options' + type: integer + example: '27' + type: + description: 'Type of attribute' + type: integer + example: 1 + global: + description: 'Possibility to use attribute in search' + type: boolean + example: 'true' + sortable: + description: 'Parameter for storefront' + type: boolean + example: 'true' + options: + description: 'Array options of attribute' + type: array + items: + type: object + properties: + value_text: + description: 'Text of attribute option' + type: string + example: 'inch' + value: + description: 'Value of attribute option' + type: float + example: '27' + +AttributeOption: + type: object + properties: + id: + type: string + example: 026bc5f6-8373-4aeb-972e-e78d72a67121 + name: + description: 'Name of attribute option' + type: string + example: 'Screen size' + index: + description: 'Index of attribute option' + type: integer + example: 1 + value_number: + description: 'Number value attribute option' + type: float + example: '27' + value_date: + description: 'Date value attribute option' + type: date + example: '2022-02-22' + attribute_id: + type: string + example: 026bc5f6-8373-4aeb-972e-e78d72a67121 + +AttributeProductList: + type: object + properties: + name: + description: 'Name of attribute' + type: string + example: 'Screen size' + selected_options: + description: 'Array options of attribute' + $ref: '#AttributeOption' + +AttributeProduct: + type: object + allOf: + - $ref: '#AttributeProductList' + - properties: + id: + type: string + example: 026bc5f6-8373-4aeb-972e-e78d72a67121 + slug: + description: 'Slug of name attribute' + type: string + example: 'screen-size' + description: + description: 'Description of attribute' + type: string + example: 'Presented number is size of screen monitor' + type: + description: 'Type of attribute' + type: integer + example: 1 + global: + description: 'Possibility to use attribute in search' + type: boolean + example: 'true' + sortable: + description: 'Parameter for storefront' + type: boolean + example: 'true' diff --git a/public/docs/schemas/Metadata.yml b/public/docs/schemas/Metadata.yml new file mode 100644 index 000000000..d26cbd40e --- /dev/null +++ b/public/docs/schemas/Metadata.yml @@ -0,0 +1,12 @@ +Metadata: + type: object + properties: + invoice: + type: string + example: 'FV/2022/03/10/005' + erp_id: + type: number + example: 132432153 + synced: + type: boolean + example: true diff --git a/public/docs/schemas/Products.yml b/public/docs/schemas/Products.yml index f67ed2c40..7f3ae76d3 100644 --- a/public/docs/schemas/Products.yml +++ b/public/docs/schemas/Products.yml @@ -46,6 +46,11 @@ Product: type: array items: $ref: './Tags.yml#/Tag' + attributes: + description: 'Array of assigned attributes' + type: array + items: + $ref: './Attributes.yml#/AttributeProductList' items: description: 'Ids of assigned items' type: array @@ -89,5 +94,10 @@ ProductView: $ref: './ProductSets.yml#/ProductSet' seo: $ref: './Seo.yml#/SeoView' + attributes: + description: 'Array of assigned attributes' + type: array + items: + $ref: './Attributes.yml#/AttributeProduct' items: $ref: './Items.yml#/ItemWithRequiredQuantity' diff --git a/routes/app.php b/routes/app.php index 30629eeff..75952638c 100644 --- a/routes/app.php +++ b/routes/app.php @@ -1,6 +1,7 @@ group(function (): void { @@ -10,6 +11,10 @@ ->middleware('can:apps.show_details'); Route::post(null, [AppController::class, 'store']) ->middleware('can:apps.install'); + Route::patch('id:{app:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:apps.install'); + Route::patch('id:{app:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:apps.install'); Route::delete('id:{app:id}', [AppController::class, 'destroy']) ->middleware('can:apps.remove'); }); diff --git a/routes/attribute.php b/routes/attribute.php new file mode 100644 index 000000000..1c9b0c433 --- /dev/null +++ b/routes/attribute.php @@ -0,0 +1,24 @@ +group(function (): void { + Route::get(null, [AttributeController::class, 'index']) + ->middleware('permission:attributes.show'); + Route::post(null, [AttributeController::class, 'store']) + ->middleware('permission:attributes.add'); + Route::get('id:{attribute:id}', [AttributeController::class, 'show']) + ->middleware('permission:attributes.show'); + Route::patch('id:{attribute:id}', [AttributeController::class, 'update']) + ->middleware('permission:attributes.edit'); + Route::delete('id:{attribute:id}', [AttributeController::class, 'destroy']) + ->middleware('can:attributes.remove'); + Route::post('id:{attribute:id}/options', [AttributeOptionController::class, 'store']) + ->middleware('permission:attributes.edit'); + Route::patch('id:{attribute:id}/options/id:{option:id}', [AttributeOptionController::class, 'update']) + ->middleware('permission:attributes.edit'); + Route::delete('id:{attribute:id}/options/id:{option:id}', [AttributeOptionController::class, 'destroy']) + ->middleware('permission:attributes.edit'); +}); diff --git a/routes/discount.php b/routes/discount.php index 955bab15c..0f8966318 100644 --- a/routes/discount.php +++ b/routes/discount.php @@ -1,6 +1,7 @@ group(function (): void { @@ -12,6 +13,10 @@ ->middleware('can:discounts.add'); Route::patch('id:{discount:id}', [DiscountController::class, 'update']) ->middleware('can:discounts.edit'); + Route::patch('id:{discount:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:discounts.edit'); + Route::patch('id:{discount:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:discounts.edit'); Route::delete('id:{discount:id}', [DiscountController::class, 'destroy']) ->middleware('can:discounts.remove'); }); diff --git a/routes/filters.php b/routes/filters.php new file mode 100644 index 000000000..78440f1ea --- /dev/null +++ b/routes/filters.php @@ -0,0 +1,6 @@ +group(function (): void { @@ -13,6 +14,10 @@ ->middleware('can:items.show_details'); Route::patch('id:{item:id}', [ItemController::class, 'update']) ->middleware('can:items.edit'); + Route::patch('id:{item:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:items.edit'); + Route::patch('id:{item:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:items.edit'); Route::delete('id:{item:id}', [ItemController::class, 'destroy']) ->middleware('can:items.remove'); diff --git a/routes/media.php b/routes/media.php index 99a9f70f8..de7b56c64 100644 --- a/routes/media.php +++ b/routes/media.php @@ -1,6 +1,7 @@ group(function (): void { @@ -10,6 +11,10 @@ ->middleware($auth); Route::patch('id:{media:id}', [MediaController::class, 'update']) ->middleware($auth); + Route::patch('id:{media:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware($auth); + Route::patch('id:{media:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware($auth); Route::delete('id:{media:id}', [MediaController::class, 'destroy']) ->middleware($auth); }); diff --git a/routes/option.php b/routes/option.php new file mode 100644 index 000000000..5a5054c80 --- /dev/null +++ b/routes/option.php @@ -0,0 +1,11 @@ +group(function (): void { + Route::patch('id:{option:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('permission:products.edit'); + Route::patch('id:{option:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('permission:products.edit'); +}); diff --git a/routes/order.php b/routes/order.php index 41cf73f4c..b8d57231a 100644 --- a/routes/order.php +++ b/routes/order.php @@ -1,5 +1,6 @@ middleware('can:orders.edit.status'); Route::patch('id:{order:id}', [OrderController::class, 'update']) ->middleware('can:orders.edit'); + Route::patch('id:{order:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:orders.edit'); + Route::patch('id:{order:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:orders.edit'); Route::get('{order:code}', [OrderController::class, 'showPublic']) ->middleware('can:orders.show_summary'); diff --git a/routes/package-template.php b/routes/package-template.php index 463e62c21..b17d02030 100644 --- a/routes/package-template.php +++ b/routes/package-template.php @@ -1,5 +1,6 @@ middleware('can:packages.add'); Route::patch('id:{package:id}', [PackageTemplateController::class, 'update']) ->middleware('can:packages.edit'); + Route::patch('id:{package:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:packages.edit'); + Route::patch('id:{package:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:packages.edit'); Route::delete('id:{package:id}', [PackageTemplateController::class, 'destroy']) ->middleware('can:packages.remove'); }); diff --git a/routes/page.php b/routes/page.php index 12a46870f..39668b4e8 100644 --- a/routes/page.php +++ b/routes/page.php @@ -1,5 +1,6 @@ middleware('can:pages.show_details'); Route::patch('id:{page:id}', [PageController::class, 'update']) ->middleware('can:pages.edit'); + Route::patch('id:{page:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:pages.edit'); + Route::patch('id:{page:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:pages.edit'); Route::delete('id:{page:id}', [PageController::class, 'destroy']) ->middleware('can:pages.remove'); Route::post('reorder', [PageController::class, 'reorder']) diff --git a/routes/product-set.php b/routes/product-set.php index b5a83c3ae..e0b2cda8d 100644 --- a/routes/product-set.php +++ b/routes/product-set.php @@ -1,5 +1,6 @@ middleware('can:product_sets.add'); Route::patch('id:{product_set:id}', [ProductSetController::class, 'update']) ->middleware('can:product_sets.edit'); + Route::patch('id:{product_set:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:product_sets.edit'); + Route::patch('id:{product_set:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:product_sets.edit'); Route::post('reorder', [ProductSetController::class, 'reorder']) ->middleware('can:product_sets.edit'); Route::post('reorder/id:{product_set:id}', [ProductSetController::class, 'reorder']) diff --git a/routes/product.php b/routes/product.php index 4f81c77e8..dbf99d56c 100644 --- a/routes/product.php +++ b/routes/product.php @@ -1,5 +1,6 @@ middleware('can:products.show_details'); Route::patch('id:{product:id}', [ProductController::class, 'update']) ->middleware('can:products.edit'); + Route::patch('id:{product:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:products.edit'); + Route::patch('id:{product:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:products.edit'); Route::delete('id:{product:id}', [ProductController::class, 'destroy']) ->middleware('can:products.remove'); }); diff --git a/routes/role.php b/routes/role.php index 7f8e4a31c..752816e12 100644 --- a/routes/role.php +++ b/routes/role.php @@ -1,5 +1,6 @@ middleware('can:roles.show_details'); Route::patch('id:{role:id}', [RoleController::class, 'update']) ->middleware('can:roles.edit'); + Route::patch('id:{role:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:roles.edit'); + Route::patch('id:{role:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:roles.edit'); Route::delete('id:{role:id}', [RoleController::class, 'destroy']) ->middleware('can:roles.remove'); }); diff --git a/routes/schema.php b/routes/schema.php index 7efe058ca..042fec461 100644 --- a/routes/schema.php +++ b/routes/schema.php @@ -1,5 +1,6 @@ middleware('permission:products.add|products.edit'); Route::patch('id:{schema:id}', [SchemaController::class, 'update']) ->middleware('permission:products.add|products.edit'); + Route::patch('id:{schema:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('permission:products.edit'); + Route::patch('id:{schema:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('permission:products.edit'); Route::delete('id:{schema:id}', [SchemaController::class, 'destroy']) ->middleware('can:schemas.remove'); }); diff --git a/routes/shipping-method.php b/routes/shipping-method.php index 8c6b735ef..bb53131a2 100644 --- a/routes/shipping-method.php +++ b/routes/shipping-method.php @@ -1,5 +1,6 @@ middleware('can:shipping_methods.add'); Route::patch('id:{shipping_method:id}', [ShippingMethodController::class, 'update']) ->middleware('can:shipping_methods.edit'); + Route::patch('id:{shipping_method:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:shipping_methods.edit'); + Route::patch('id:{shipping_method:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:shipping_methods.edit'); Route::delete('id:{shipping_method:id}', [ShippingMethodController::class, 'destroy']) ->middleware('can:shipping_methods.remove'); Route::post('reorder', [ShippingMethodController::class, 'reorder']) diff --git a/routes/status.php b/routes/status.php index d0634f3c8..4a4cfff8f 100644 --- a/routes/status.php +++ b/routes/status.php @@ -1,5 +1,6 @@ middleware('can:statuses.add'); Route::patch('id:{status:id}', [StatusController::class, 'update']) ->middleware('can:statuses.edit'); + Route::patch('id:{status:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:statuses.edit'); + Route::patch('id:{status:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:statuses.edit'); Route::post('reorder', [StatusController::class, 'reorder']) ->middleware('can:statuses.edit'); Route::delete('id:{status:id}', [StatusController::class, 'destroy']) diff --git a/routes/user.php b/routes/user.php index c65196060..706921000 100644 --- a/routes/user.php +++ b/routes/user.php @@ -1,6 +1,7 @@ middleware('can:users.add'); Route::patch('id:{user:id}', [UserController::class, 'update']) ->middleware('can:users.edit'); + Route::patch('id:{user:id}/metadata', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:users.edit'); + Route::patch('id:{user:id}/metadata-private', [MetadataController::class, 'updateOrCreate']) + ->middleware('can:users.edit'); Route::delete('id:{user:id}', [UserController::class, 'destroy']) ->middleware('can:users.remove'); }); diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php index b3365feb7..d09614712 100644 --- a/tests/CreatesApplication.php +++ b/tests/CreatesApplication.php @@ -19,6 +19,7 @@ trait CreatesApplication 'view:clear', 'config:clear', 'route:clear', + 'scout:index products', ]; /** diff --git a/tests/Feature/AppInstallTest.php b/tests/Feature/AppInstallTest.php index b3fa831bf..db991ddc2 100644 --- a/tests/Feature/AppInstallTest.php +++ b/tests/Feature/AppInstallTest.php @@ -246,6 +246,7 @@ public function testInstall($user): void 'version' => '1.0.0', 'description' => 'Cool description', 'icon' => 'https://picsum.photos/200', + 'metadata' => [], ]); $this->assertDatabaseHas('apps', [ @@ -374,6 +375,7 @@ public function testInstallWithOptionalPermissions($user): void 'version' => '1.0.0', 'description' => 'Cool description', 'icon' => 'https://picsum.photos/200', + 'metadata' => [], ]); $this->assertDatabaseHas('apps', [ diff --git a/tests/Feature/AppOtherTest.php b/tests/Feature/AppOtherTest.php index 5b95fa42a..0d971a701 100644 --- a/tests/Feature/AppOtherTest.php +++ b/tests/Feature/AppOtherTest.php @@ -67,6 +67,7 @@ public function testShow(): void 'icon' => $app->icon, 'author' => $app->author, 'permissions' => [], + 'metadata' => [], ], ]); } diff --git a/tests/Feature/AttributeTest.php b/tests/Feature/AttributeTest.php new file mode 100644 index 000000000..ab6e28078 --- /dev/null +++ b/tests/Feature/AttributeTest.php @@ -0,0 +1,1159 @@ +attribute = Attribute::factory()->create(); + + $this->option = AttributeOption::factory()->create([ + 'index' => 1, + 'attribute_id' => $this->attribute->getKey(), + ]); + + $this->attribute->refresh(); + + $this->newAttribute = Attribute::factory()->definition(); + $this->newAttribute['options'] = [ + AttributeOption::factory()->definition(), + AttributeOption::factory()->definition(), + ]; + + $this->newOption = AttributeOption::factory()->definition(); + + $this->expectedStructure = [ + 'data' => [ + 'name', + 'slug', + 'description', + 'min', + 'max', + 'type', + 'global', + 'sortable', + 'options', + ], + ]; + } + + public function testIndexUnauthorized(): void + { + $response = $this->getJson('/attributes'); + $response->assertForbidden(); + } + + /** + * @dataProvider authProvider + */ + public function testIndex($user): void + { + $this->$user->givePermissionTo('attributes.show'); + + $this->newAttribute['global'] = !$this->attribute->global; + unset($this->newAttribute['options']); + Attribute::create($this->newAttribute); + + $this + ->actingAs($this->$user) + ->getJson('/attributes') + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonFragment([ + 'name' => $this->attribute->name, + 'slug' => $this->attribute->slug, + 'description' => $this->attribute->description, + 'type' => $this->attribute->type, + 'global' => $this->attribute->global, + 'sortable' => $this->attribute->sortable, + ]) + ->assertJsonFragment([ + 'index' => $this->option->index, + 'name' => $this->option->name, + 'value_number' => $this->option->value_number, + 'value_date' => $this->option->value_date, + ]) + ->assertJsonFragment($this->newAttribute); + } + + /** + * @dataProvider authProvider + */ + public function testIndexGlobalFlagTrue($user): void + { + $this->$user->givePermissionTo('attributes.show'); + + $this->newAttribute['global'] = true; + unset($this->newAttribute['options']); + Attribute::create($this->newAttribute); + + $this + ->actingAs($this->$user) + ->getJson('/attributes?global=1') + ->assertOk() + ->assertJsonMissing(['global' => false]) + ->assertJsonFragment($this->newAttribute); + } + + /** + * @dataProvider authProvider + */ + public function testIndexGlobalFlagFalse($user): void + { + $this->$user->givePermissionTo('attributes.show'); + + $this->newAttribute['global'] = false; + unset($this->newAttribute['options']); + Attribute::create($this->newAttribute); + + $this + ->actingAs($this->$user) + ->getJson('/attributes?global=0') + ->assertOk() + ->assertJsonMissing(['global' => true]) + ->assertJsonFragment($this->newAttribute); + } + + /** + * @dataProvider authProvider + */ + public function testShow($user): void + { + $this->$user->givePermissionTo('attributes.show'); + + $this + ->actingAs($this->$user) + ->getJson('/attributes/id:'. $this->attribute->getKey()) + ->assertOk() + ->assertJsonFragment([ + 'name' => $this->attribute->name, + 'slug' => $this->attribute->slug, + 'description' => $this->attribute->description, + 'type' => $this->attribute->type, + 'global' => $this->attribute->global, + 'sortable' => $this->attribute->sortable, + ]) + ->assertJsonFragment([ + 'index' => $this->option->index, + 'name' => $this->option->name, + 'value_number' => $this->option->value_number, + 'value_date' => $this->option->value_date, + ]); + } + + /** + * @dataProvider authProvider + */ + public function testShowMinMaxNumber($user): void + { + $this->$user->givePermissionTo('attributes.show'); + + $attribute = Attribute::create([ + 'name' => 'Monitor screen size', + 'slug' => 'monitor-screen-size', + 'description' => 'MinMax attribute number description', + 'type' => AttributeType::NUMBER, + 'global' => true, + 'sortable' => true, + ]); + + $option1 = AttributeOption::create([ + 'index' => 1, + 'name' => 'Modern screen size', + 'value_number' => 27, + 'value_date' => null, + 'attribute_id' => $attribute->getKey(), + ]); + + $option2 = AttributeOption::create([ + 'index' => 2, + 'name' => 'Old screen size', + 'value_number' => 15, + 'value_date' => null, + 'attribute_id' => $attribute->getKey(), + ]); + + $this + ->actingAs($this->$user) + ->getJson('/attributes/id:'. $attribute->getKey()) + ->assertOk() + ->assertJsonFragment([ + 'name' => $attribute->name, + 'slug' => $attribute->slug, + 'description' => $attribute->description, + 'min' => $option2->value_number, + 'max' => $option1->value_number, + 'type' => $attribute->type, + 'global' => $attribute->global, + 'sortable' => $attribute->sortable, + ]) + ->assertJsonFragment([ + 'index' => $option1->index, + 'name' => $option1->name, + 'value_number' => $option1->value_number, + 'value_date' => $option1->value_date, + 'attribute_id' => $option1->attribute_id, + ]) + ->assertJsonFragment([ + 'index' => $option2->index, + 'name' => $option2->name, + 'value_number' => $option2->value_number, + 'value_date' => $option2->value_date, + 'attribute_id' => $option2->attribute_id, + ]); + + //checking rest of min/max fields in attribute + $this->assertDatabaseHas('attributes', [ + 'id' => $attribute->getKey(), + 'min_date' => null, + 'max_date' => null, + ]); + //making sure to other attributes was not updated by this one + $this->assertDatabaseMissing('attributes', [ + 'id' => $this->attribute->getKey(), + 'min_number' => $option2->value_number, + 'max_number' => $option1->value_number, + ]); + } + + /** + * @dataProvider authProvider + */ + public function testShowMinMaxDate($user): void + { + $this->$user->givePermissionTo('attributes.show'); + + $attribute = Attribute::create([ + 'name' => 'Release date', + 'slug' => 'release-date', + 'description' => 'MinMax attribute date description', + 'type' => AttributeType::DATE, + 'global' => true, + 'sortable' => true, + ]); + + $option1 = AttributeOption::create([ + 'index' => 1, + 'name' => 'Book #1', + 'value_number' => null, + 'value_date' => '2000-03-25', + 'attribute_id' => $attribute->getKey(), + ]); + + $option2 = AttributeOption::create([ + 'index' => 2, + 'name' => 'Book #2', + 'value_number' => null, + 'value_date' => '1999-02-01', + 'attribute_id' => $attribute->getKey(), + ]); + + $this + ->actingAs($this->$user) + ->getJson('/attributes/id:'. $attribute->getKey()) + ->assertOk() + ->assertJsonFragment([ + 'name' => $attribute->name, + 'slug' => $attribute->slug, + 'description' => $attribute->description, + 'min' => $option2->value_date, + 'max' => $option1->value_date, + 'type' => $attribute->type, + 'global' => $attribute->global, + 'sortable' => $attribute->sortable, + ]) + ->assertJsonFragment([ + 'index' => $option1->index, + 'name' => $option1->name, + 'value_number' => $option1->value_number, + 'value_date' => $option1->value_date, + 'attribute_id' => $option1->attribute_id, + ]) + ->assertJsonFragment([ + 'index' => $option2->index, + 'name' => $option2->name, + 'value_number' => $option2->value_number, + 'value_date' => $option2->value_date, + 'attribute_id' => $option2->attribute_id, + ]); + + //checking rest of min/max fields in attribute + $this->assertDatabaseHas('attributes', [ + 'id' => $attribute->getKey(), + 'min_number' => null, + 'max_number' => null, + ]); + //making sure to other attributes was not updated by this one + $this->assertDatabaseMissing('attributes', [ + 'id' => $this->attribute->getKey(), + 'min_date' => $option2->value_date, + 'max_date' => $option1->value_date, + ]); + } + + /** + * @dataProvider authProvider + */ + public function testCreate($user): void + { + $this->$user->givePermissionTo('attributes.add'); + + $this + ->actingAs($this->$user) + ->postJson('/attributes', $this->newAttribute) + ->assertCreated() + ->assertJsonStructure($this->expectedStructure) + ->assertJsonFragment([ + 'name' => $this->newAttribute['name'], + 'slug' => $this->newAttribute['slug'], + 'description' => $this->newAttribute['description'], + 'type' => $this->newAttribute['type'], + 'global' => $this->newAttribute['global'], + 'sortable' => $this->newAttribute['sortable'], + ]) + ->assertJsonFragment(['index' => 1] + $this->newAttribute['options'][0]) + ->assertJsonFragment(['index' => 2] + $this->newAttribute['options'][1]); + } + + /** + * @dataProvider authProvider + */ + public function testCreateSingleOptionAndOptionWithoutName($user): void + { + $this->$user->givePermissionTo('attributes.add'); + + $this->newAttribute['type'] = AttributeType::SINGLE_OPTION; + unset($this->newAttribute['options']); + unset($this->newOption['name']); + $this->newAttribute['options'] = [$this->newOption]; + + $this + ->actingAs($this->$user) + ->postJson('/attributes', $this->newAttribute) + ->assertUnprocessable(); + } + + /** + * @dataProvider authProvider + */ + public function testCreateWithInvalidValueNumber($user): void + { + $this->$user->givePermissionTo('attributes.add'); + + $attribute = Attribute::factory()->make([ + 'type' => AttributeType::SINGLE_OPTION, + ]); + $attribute['options'] = [ + AttributeOption::factory()->make([ + 'value_number' => 9999999.99, + ]), + ]; + + $response = $this + ->actingAs($this->$user) + ->postJson('/attributes', $attribute->toArray()); + + $response->assertUnprocessable(); + } + + /** + * @dataProvider authProvider + */ + public function testCreateIncompleteData($user): void + { + $this->$user->givePermissionTo('attributes.add'); + + unset($this->newAttribute['name']); + + $this + ->actingAs($this->$user) + ->postJson('/attributes', $this->newAttribute) + ->assertUnprocessable(); + } + + /** + * @dataProvider authProvider + */ + public function testCreateUnauthorized($user): void + { + $this + ->actingAs($this->$user) + ->postJson('/attributes', $this->newAttribute) + ->assertForbidden(); + } + + /** + * @dataProvider authProvider + */ + public function testUpdate($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $attributeUpdate = [ + 'name' => 'Test ' . $this->attribute->name, + 'slug' => 'test-' . $this->attribute->slug, + 'description' => 'Test ' . $this->attribute->description, + 'type' => $this->attribute->type, + 'global' => true, + 'sortable' => true, + 'options' => [ + [ + 'id' => $this->option->getKey(), + 'name' => 'Test ' . $this->option->name, + 'value_number' => $this->option->value_number, + 'value_date' => $this->option->value_date, + ], + ], + ]; + + $this + ->actingAs($this->$user) + ->patchJson('/attributes/id:' . $this->attribute->getKey(), $attributeUpdate) + ->assertOk() + ->assertJsonStructure($this->expectedStructure) + ->assertJsonFragment([ + 'name' => $attributeUpdate['name'], + 'slug' => $attributeUpdate['slug'], + 'description' => $attributeUpdate['description'], + 'type' => $attributeUpdate['type'], + 'global' => $attributeUpdate['global'], + 'sortable' => $attributeUpdate['sortable'], + ]) + ->assertJsonFragment($attributeUpdate['options'][0]); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateWithoutSlug($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $attributeUpdate = [ + 'name' => 'Test ' . $this->attribute->name, + 'slug' => $this->attribute->slug, + 'description' => 'Test ' . $this->attribute->description, + 'type' => $this->attribute->type, + 'global' => true, + 'sortable' => true, + 'options' => [ + [ + 'id' => $this->option->getKey(), + 'name' => 'Test ' . $this->option->name, + 'value_number' => $this->option->value_number, + 'value_date' => $this->option->value_date, + ], + ], + ]; + + $this + ->actingAs($this->$user) + ->patchJson('/attributes/id:' . $this->attribute->getKey(), $attributeUpdate) + ->assertOk() + ->assertJsonStructure($this->expectedStructure) + ->assertJsonFragment([ + 'name' => $attributeUpdate['name'], + 'slug' => $attributeUpdate['slug'], + 'description' => $attributeUpdate['description'], + 'type' => $attributeUpdate['type'], + 'global' => $attributeUpdate['global'], + 'sortable' => $attributeUpdate['sortable'], + ]) + ->assertJsonFragment($attributeUpdate['options'][0]); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateChangeType($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + while (true) { + $randomType = AttributeType::getRandomValue(); + + if ($randomType !== $this->attribute->type->value) { + $this->attribute->type = $randomType; + break; + } + } + + $attributeUpdate = [ + 'name' => 'Test ' . $this->attribute->name, + 'slug' => $this->attribute->slug, + 'description' => 'Test ' . $this->attribute->description, + 'type' => $this->attribute->type, + 'global' => true, + 'sortable' => true, + 'options' => [ + [ + 'id' => $this->option->getKey(), + 'name' => 'Test ' . $this->option->name, + 'value_number' => $this->option->value_number, + 'value_date' => $this->option->value_date, + ], + ], + ]; + + $this + ->actingAs($this->$user) + ->patchJson('/attributes/id:' . $this->attribute->getKey(), $attributeUpdate) + ->assertUnprocessable(); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateIncompleteData($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $attributeUpdate = [ + 'name' => 'Test update attribute name', + ]; + + $this + ->actingAs($this->$user) + ->patchJson('/attributes/id:' . $this->attribute->getKey(), $attributeUpdate) + ->assertUnprocessable(); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateNotExistingAttribute($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + Attribute::destroy($this->attribute->getKey()); + + $attributeUpdate = [ + 'name' => 'Test update attribute name', + ]; + + $this + ->actingAs($this->$user) + ->patchJson('/attributes/id:' . $this->attribute->getKey(), $attributeUpdate) + ->assertNotFound(); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateWithoutAssignedOption($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $attributeUpdate = [ + 'name' => 'Test ' . $this->attribute->name, + 'slug' => 'test-' . $this->attribute->slug, + 'description' => 'Test ' . $this->attribute->description, + 'type' => $this->attribute->type, + 'global' => true, + 'sortable' => true, + 'options' => [ + [ + 'name' => 'Totally different option', + 'value_number' => $this->option->value_number, + 'value_date' => $this->option->value_date, + ], + ], + ]; + + $this + ->actingAs($this->$user) + ->patchJson('/attributes/id:' . $this->attribute->getKey(), $attributeUpdate) + ->assertOk() + ->assertJsonStructure($this->expectedStructure) + ->assertJsonFragment([ + 'name' => $attributeUpdate['name'], + 'slug' => $attributeUpdate['slug'], + 'description' => $attributeUpdate['description'], + 'type' => $attributeUpdate['type'], + 'global' => $attributeUpdate['global'], + 'sortable' => $attributeUpdate['sortable'], + ]) + ->assertJsonFragment($attributeUpdate['options'][0]) + ->assertJsonMissing(['id' => $this->option->getKey()]); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateUnauthorized($user): void + { + $attributeUpdate = [ + 'name' => 'Test ' . $this->attribute->name, + 'slug' => 'test-' . $this->attribute->slug, + 'description' => 'Test ' . $this->attribute->description, + 'type' => AttributeType::NUMBER, + 'global' => true, + 'sortable' => true, + 'options' => [ + [ + 'id' => $this->option->id, + 'name' => 'Test ' . $this->option->name, + 'value_number' => $this->option->value_number, + 'value_date' => $this->option->value_date, + ], + ], + ]; + + $this + ->actingAs($this->$user) + ->patchJson('/attributes/id:' . $this->attribute->getKey(), $attributeUpdate) + ->assertForbidden(); + } + + /** + * @dataProvider authProvider + */ + public function testDelete($user): void + { + $this->$user->givePermissionTo('attributes.remove'); + + $this + ->actingAs($this->$user) + ->deleteJson('/attributes/id:' . $this->attribute->getKey()) + ->assertNoContent(); + + $this->assertDatabaseMissing('attributes', [ + 'id' => $this->attribute->getKey(), + ]); + } + + /** + * @dataProvider authProvider + */ + public function testDeleteNotExistingAttribute($user): void + { + $this->$user->givePermissionTo('attributes.remove'); + + Attribute::destroy($this->attribute->getKey()); + + $this + ->actingAs($this->$user) + ->deleteJson('/attributes/id:' . $this->attribute->getKey()) + ->assertNotFound(); + } + + /** + * @dataProvider authProvider + */ + public function testDeleteUnauthorized($user): void + { + $this + ->actingAs($this->$user) + ->deleteJson('/attributes/id:' . $this->attribute->getKey()) + ->assertForbidden(); + } + + /** + * @dataProvider authProvider + */ + public function testAddOption($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $this + ->actingAs($this->$user) + ->postJson('/attributes/id:' . $this->attribute->getKey() . '/options', $this->newOption) + ->assertCreated() + ->assertJsonFragment($this->newOption); + + $this->assertDatabaseHas('attribute_options', $this->newOption); + } + + /** + * @dataProvider authProvider + */ + public function testAddOptionNumberWithoutName($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $attribute = Attribute::factory([ + 'type' => AttributeType::NUMBER, + ])->create(); + unset($this->newOption['name']); + + $this + ->actingAs($this->$user) + ->postJson('/attributes/id:' . $attribute->getKey() . '/options', $this->newOption) + ->assertCreated() + ->assertJsonFragment($this->newOption); + + $this->assertDatabaseHas('attribute_options', $this->newOption); + } + + /** + * @dataProvider authProvider + */ + public function testAddOptionIncompleteData($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $attribute = Attribute::factory([ + 'type' => AttributeType::SINGLE_OPTION, + ])->create(); + unset($this->newOption['name']); + + $this + ->actingAs($this->$user) + ->postJson('/attributes/id:' . $attribute->getKey() . '/options', $this->newOption) + ->assertUnprocessable(); + } + + /** + * @dataProvider authProvider + */ + public function testAddOptionToDeletedAttribute($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + Attribute::destroy($this->attribute->getKey()); + + $this + ->actingAs($this->$user) + ->postJson('/attributes/id:' . $this->attribute->getKey() . '/options', $this->newOption) + ->assertNotFound(); + } + + /** + * @dataProvider authProvider + */ + public function testAddOptionUnauthorized($user): void + { + $this + ->actingAs($this->$user) + ->postJson('/attributes/id:' . $this->attribute->getKey() . '/options', $this->newOption) + ->assertForbidden(); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateOption($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $optionUpdate = [ + 'id' => $this->option->id, + 'name' => 'Test ' . $this->option->name, + 'value_number' => $this->option->value_number + 1, + 'value_date' => Carbon::now()->toDateString(), + 'attribute_id' => $this->option->attribute_id, + ]; + + $this + ->actingAs($this->$user) + ->json( + 'PATCH', + '/attributes/id:' . $this->attribute->getKey() . '/options/id:'. $this->option->getKey(), + $optionUpdate + ) + ->assertOk() + ->assertJsonFragment($optionUpdate); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateOptionWithoutId($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $optionUpdate = [ + 'name' => 'Test ' . $this->option->name, + 'value_number' => $this->option->value_number + 1, + 'value_date' => Carbon::now()->toDateString(), + 'attribute_id' => $this->option->attribute_id, + ]; + + $this + ->actingAs($this->$user) + ->json( + 'PATCH', + '/attributes/id:' . $this->attribute->getKey() . '/options/id:'. $this->option->getKey(), + $optionUpdate + ) + ->assertOk() + ->assertJsonFragment($optionUpdate); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateOptionIncompleteData($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $attribute = Attribute::factory([ + 'type' => AttributeType::SINGLE_OPTION, + ])->create(); + + $option = AttributeOption::factory()->create([ + 'index' => 1, + 'attribute_id' => $attribute->getKey(), + ]); + + $optionUpdate = [ + 'value_number' => $option->value_number + 1, + 'value_date' => Carbon::now()->toDateString(), + 'attribute_id' => $option->attribute_id, + ]; + + $this + ->actingAs($this->$user) + ->json( + 'PATCH', + '/attributes/id:' . $attribute->getKey() . '/options/id:'. $option->getKey(), + $optionUpdate + ) + ->assertUnprocessable(); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateOptionNotExisting($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $optionUpdate = [ + 'id' => $this->option->id, + 'name' => 'Test ' . $this->option->name, + 'value_number' => $this->option->value_number + 1, + 'value_date' => Carbon::now()->toDateString(), + 'attribute_id' => $this->option->attribute_id, + ]; + + $this->option->delete(); + + $this + ->actingAs($this->$user) + ->json( + 'PATCH', + '/attributes/id:' . $this->attribute->getKey() . '/options/id:'. $this->option->getKey(), + $optionUpdate + ) + ->assertNotFound(); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateOptionNotRelatedOption($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $attribute = Attribute::factory()->create(); + + $optionUpdate = [ + 'id' => $this->option->id, + 'name' => 'Test ' . $this->option->name, + 'value_number' => $this->option->value_number + 1, + 'value_date' => Carbon::now()->toDateString(), + 'attribute_id' => $this->option->attribute_id, + ]; + + $this + ->actingAs($this->$user) + ->json( + 'PATCH', + '/attributes/id:' . $attribute->getKey() . '/options/id:'. $this->option->getKey(), + $optionUpdate + ) + ->assertNotFound(); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateOptionUnauthorized($user): void + { + $optionUpdate = [ + 'id' => $this->option->id, + 'name' => 'Test ' . $this->option->name, + 'value_number' => $this->option->value_number + 1, + 'value_date' => Carbon::now()->toDateString(), + 'attribute_id' => $this->option->attribute_id, + ]; + + $this + ->actingAs($this->$user) + ->patchJson( + '/attributes/id:' . $this->attribute->getKey() . '/options/id:'. $this->option->getKey(), + $optionUpdate + ) + ->assertForbidden(); + } + + /** + * @dataProvider authProvider + */ + public function testDeleteOption($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $this + ->actingAs($this->$user) + ->deleteJson('/attributes/id:' . $this->attribute->getKey() . '/options/id:'. $this->option->getKey()) + ->assertNoContent(); + + $this->assertSoftDeleted($this->option); + } + + /** + * @dataProvider authProvider + */ + public function testDeleteOptionNotExisting($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $this->option->delete(); + + $this + ->actingAs($this->$user) + ->deleteJson('/attributes/id:' . $this->attribute->getKey() . '/options/id:'. $this->option->getKey()) + ->assertNotFound(); + } + + /** + * @dataProvider authProvider + */ + public function testDeleteOptionNotRelatedOption($user): void + { + $this->$user->givePermissionTo('attributes.edit'); + + $attribute = Attribute::factory()->create(); + + $this + ->actingAs($this->$user) + ->deleteJson('/attributes/id:' . $attribute->getKey() . '/options/id:'. $this->option->getKey()) + ->assertNotFound(); + } + + /** + * @dataProvider authProvider + */ + public function testDeleteOptionUnauthorized($user): void + { + $this + ->actingAs($this->$user) + ->deleteJson('/attributes/id:' . $this->attribute->getKey() . '/options/id:'. $this->option->getKey()) + ->assertForbidden(); + } + + /** + * @dataProvider authProvider + */ + public function testIncrementIndex($user): void + { + $this->$user->givePermissionTo(['attributes.show', 'attributes.edit', 'attributes.add']); + + $response = $this + ->actingAs($this->$user) + ->postJson('/attributes', $this->newAttribute) + ->assertCreated() + ->assertJsonStructure($this->expectedStructure) + ->assertJsonFragment([ + 'name' => $this->newAttribute['name'], + 'slug' => $this->newAttribute['slug'], + 'description' => $this->newAttribute['description'], + 'type' => $this->newAttribute['type'], + 'global' => $this->newAttribute['global'], + 'sortable' => $this->newAttribute['sortable'], + ]) + ->assertJsonFragment(['index' => 1] + $this->newAttribute['options'][0]) + ->assertJsonFragment(['index' => 2] + $this->newAttribute['options'][1]); + + AttributeOption::query() + ->where('attribute_id', '=', $response['data']['id']) + ->where('index', '=', 2) + ->delete(); + + $this->assertSoftDeleted('attribute_options', [ + 'attribute_id' => $response['data']['id'], + 'index' => 2, + ]); + + $this + ->actingAs($this->$user) + ->postJson('/attributes/id:' . $response['data']['id'] . '/options', $this->newOption) + ->assertCreated() + ->assertJsonFragment(['index' => 3] + $this->newOption); + + $this + ->actingAs($this->$user) + ->getJson('/attributes/id:' . $response['data']['id']) + ->assertOk() + ->assertJsonFragment([ + 'name' => $this->newAttribute['name'], + 'slug' => $this->newAttribute['slug'], + 'description' => $this->newAttribute['description'], + 'type' => $this->newAttribute['type'], + 'global' => $this->newAttribute['global'], + 'sortable' => $this->newAttribute['sortable'], + ]) + ->assertJsonFragment(['index' => 1]) + ->assertJsonMissing(['index' => 2]) + ->assertJsonFragment(['index' => 3]); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateMinMaxNumberOnUpdateOption($user): void + { + $this->$user->givePermissionTo('attributes.show'); + + $attribute = Attribute::factory([ + 'type' => AttributeType::NUMBER, + ])->create(); + + $option1 = AttributeOption::factory()->create([ + 'index' => 1, + 'value_number' => 100, + 'attribute_id' => $attribute->getKey(), + ]); + + $option2 = AttributeOption::factory()->create([ + 'index' => 1, + 'value_number' => 200, + 'attribute_id' => $attribute->getKey(), + ]); + + $option1->update(['value_number' => 110]); + $option2->update(['value_number' => 190]); + + $this + ->actingAs($this->$user) + ->getJson('/attributes/id:'. $attribute->getKey()) + ->assertOk() + ->assertJsonFragment([ + 'min' => 110, + 'max' => 190, + ]); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateMinMaxNumberOnDeleteOption($user): void + { + $this->$user->givePermissionTo('attributes.show'); + + $attribute = Attribute::factory([ + 'type' => AttributeType::NUMBER, + ])->create(); + + AttributeOption::factory()->create([ + 'index' => 1, + 'value_number' => 100, + 'attribute_id' => $attribute->getKey(), + ]); + + $option2 = AttributeOption::factory()->create([ + 'index' => 1, + 'value_number' => 200, + 'attribute_id' => $attribute->getKey(), + ]); + + $option2->delete(); + + $this + ->actingAs($this->$user) + ->getJson('/attributes/id:'. $attribute->getKey()) + ->assertOk() + ->assertJsonFragment([ + 'min' => 100, + 'max' => 100, + ]); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateMinMaxDateOnUpdateOption($user): void + { + $this->$user->givePermissionTo('attributes.show'); + + $attribute = Attribute::factory([ + 'type' => AttributeType::DATE, + ])->create(); + + $option1 = AttributeOption::factory()->create([ + 'index' => 1, + 'value_date' => '2010-03-15', + 'attribute_id' => $attribute->getKey(), + ]); + + $option2 = AttributeOption::factory()->create([ + 'index' => 1, + 'value_date' => '2020-03-15', + 'attribute_id' => $attribute->getKey(), + ]); + + $option1->update(['value_date' => '2012-08-10']); + $option2->update(['value_date' => '2019-01-01']); + + $this + ->actingAs($this->$user) + ->getJson('/attributes/id:'. $attribute->getKey()) + ->assertOk() + ->assertJsonFragment([ + 'min' => '2012-08-10', + 'max' => '2019-01-01', + ]); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateMinMaxDateOnDeleteOption($user): void + { + $this->$user->givePermissionTo('attributes.show'); + + $attribute = Attribute::factory([ + 'type' => AttributeType::DATE, + ])->create(); + + AttributeOption::factory()->create([ + 'index' => 1, + 'value_date' => '2010-03-15', + 'attribute_id' => $attribute->getKey(), + ]); + + $option2 = AttributeOption::factory()->create([ + 'index' => 1, + 'value_date' => '2020-03-15', + 'attribute_id' => $attribute->getKey(), + ]); + + $option2->delete(); + + $this + ->actingAs($this->$user) + ->getJson('/attributes/id:'. $attribute->getKey()) + ->assertOk() + ->assertJsonFragment([ + 'min' => '2010-03-15', + 'max' => '2010-03-15', + ]); + } +} diff --git a/tests/Feature/DiscountTest.php b/tests/Feature/DiscountTest.php index a3edc32b8..c3aace981 100644 --- a/tests/Feature/DiscountTest.php +++ b/tests/Feature/DiscountTest.php @@ -129,6 +129,7 @@ public function testCreate($user): void 'available' => true, 'starts_at' => Carbon::yesterday(), 'expires_at' => Carbon::tomorrow(), + 'metadata' => [], ]); $this->assertDatabaseHas('discounts', [ @@ -300,6 +301,7 @@ public function testUpdate($user): void 'type' => DiscountType::AMOUNT, 'starts_at' => Carbon::yesterday(), 'expires_at' => Carbon::tomorrow(), + 'metadata' => [], ]); $this->assertDatabaseHas('discounts', [ @@ -592,6 +594,7 @@ public function testCreateCheckDatetime($user): void 'uses' => 0, 'starts_at' => '2021-09-20T12:00:00.000000Z', 'expires_at' => '2021-09-21T12:00:00.000000Z', + 'metadata' => [], ]); $this->assertDatabaseHas('discounts', [ diff --git a/tests/Feature/FilterTest.php b/tests/Feature/FilterTest.php new file mode 100644 index 000000000..49353d437 --- /dev/null +++ b/tests/Feature/FilterTest.php @@ -0,0 +1,104 @@ +count(3)->create(['global' => 1]); + Attribute::factory()->create(['global' => 0]); + + $this + ->actingAs($this->$user) + ->json('GET', '/filters') + ->assertJsonCount(3, 'data'); + } + + /** + * @dataProvider authProvider + */ + public function testFilterWithSetsIds($user): void + { + $singleOptionAttribute = Attribute::factory()->create([ + 'global' => 0, + 'type' => 'single-option', + ]); + + AttributeOption::create([ + 'name' => 'test', + 'value_number' => 1, + 'index' => 0, + 'attribute_id' => $singleOptionAttribute->getKey(), + ]); + AttributeOption::create([ + 'name' => 'test2', + 'value_number' => 99, + 'index' => 1, + 'attribute_id' => $singleOptionAttribute->getKey(), + ]); + + $firstProductSet = ProductSet::factory()->create(); + $firstProductSet->attributes()->attach([ + Attribute::factory()->create([ + 'global' => 0, + 'type' => 'number', + ])->getKey(), + Attribute::factory()->create([ + 'global' => 0, + 'type' => 'date', + ])->getKey(), + ]); + + $secondProductSet = ProductSet::factory()->create(); + $secondProductSet->attributes()->attach([ + Attribute::factory()->create([ + 'global' => 0, + 'type' => 'number', + ])->getKey(), + $singleOptionAttribute->getKey(), + Attribute::factory()->create([ + 'global' => 0, + 'type' => 'date', + ])->getKey(), + ]); + + ProductSet::factory()->create()->attributes()->attach([ + Attribute::factory()->create([ + 'global' => 0, + 'type' => 'number', + ])->getKey(), + Attribute::factory()->create([ + 'global' => 1, + 'type' => 'date', + ])->getKey(), + Attribute::factory()->create([ + 'global' => 0, + 'type' => 'single-option', + ])->getKey(), + ]); + + $this + ->actingAs($this->$user) + ->json('GET', '/filters', [ + 'sets' => [$firstProductSet->getKey(), $secondProductSet->getKey()], + ]) + ->assertJsonFragment([ + 'name' => 'test', + 'value_number' => 1, + ]) + ->assertJsonFragment([ + 'name' => 'test2', + 'value_number' => 99, + ]) + ->assertJsonCount(6, 'data'); + } +} diff --git a/tests/Feature/ItemTest.php b/tests/Feature/ItemTest.php index 40c673b0e..af5f2d1ed 100644 --- a/tests/Feature/ItemTest.php +++ b/tests/Feature/ItemTest.php @@ -42,6 +42,7 @@ public function setUp(): void 'name' => $this->item->name, 'sku' => $this->item->sku, 'quantity' => $this->item->quantity, + 'metadata' => [], ]; } @@ -68,7 +69,7 @@ public function testIndex($user): void ], ]); - $this->assertQueryCountLessThan(10); + $this->assertQueryCountLessThan(11); } /** @@ -86,7 +87,7 @@ public function testIndexPerformance($user): void ->assertOk() ->assertJsonCount(500, 'data'); - $this->assertQueryCountLessThan(10); + $this->assertQueryCountLessThan(11); } /** @@ -122,7 +123,7 @@ public function testIndexFilterByAvailable($user): void ], ]); - $this->assertQueryCountLessThan(10); + $this->assertQueryCountLessThan(11); } /** @@ -155,7 +156,7 @@ public function testIndexFilterBySoldOut($user): void ], ]); - $this->assertQueryCountLessThan(10); + $this->assertQueryCountLessThan(11); } /** @@ -227,7 +228,7 @@ public function testIndexFilterByDay($user): void 'quantity' => 5, ]); - $this->assertQueryCountLessThan(10); + $this->assertQueryCountLessThan(11); } /** @@ -265,7 +266,7 @@ public function testIndexFilterByDayWithHour($user): void 'quantity' => 5, ]); - $this->assertQueryCountLessThan(10); + $this->assertQueryCountLessThan(11); } public function testViewUnauthorized(): void diff --git a/tests/Feature/MediaTest.php b/tests/Feature/MediaTest.php index 25a97d38c..f6a27ac67 100644 --- a/tests/Feature/MediaTest.php +++ b/tests/Feature/MediaTest.php @@ -53,6 +53,7 @@ public function testUpload($user): void 'url', 'slug', 'alt', + 'metadata', ], ]); } diff --git a/tests/Feature/MetadataFilterTest.php b/tests/Feature/MetadataFilterTest.php new file mode 100644 index 000000000..bf4db30da --- /dev/null +++ b/tests/Feature/MetadataFilterTest.php @@ -0,0 +1,545 @@ + [ + 'user', + [ + 'model' => Schema::class, + 'prefix_url' => 'schemas', + 'public_role' => 'products.add', + 'private_role' => 'schemas.show_metadata_private', + ], + ], + 'schemas as application' => [ + 'application', + ['model' => Schema::class, + 'prefix_url' => 'schemas', + 'public_role' => 'products.add', + 'private_role' => 'schemas.show_metadata_private', + ], + ], + + 'product sets as user' => [ + 'user', + [ + 'model' => ProductSet::class, + 'prefix_url' => 'product-sets', + 'public_role' => 'product_sets.show', + 'private_role' => 'product_sets.show_metadata_private', + ], + ], + 'product sets as application' => [ + 'application', + [ + 'model' => ProductSet::class, + 'prefix_url' => 'product-sets', + 'public_role' => 'product_sets.show', + 'private_role' => 'product_sets.show_metadata_private', + ], + ], + + 'discounts as user' => [ + 'user', [ + 'model' => Discount::class, + 'prefix_url' => 'discounts', + 'public_role' => 'discounts.show', + 'private_role' => 'discounts.show_metadata_private', + ], + ], + 'discounts as application' => [ + 'application', + [ + 'model' => Discount::class, + 'prefix_url' => 'discounts', + 'public_role' => 'discounts.show', + 'private_role' => 'discounts.show_metadata_private', + ], + ], + + 'items as user' => [ + 'user', + [ + 'model' => Item::class, + 'prefix_url' => 'items', + 'public_role' => 'items.show', + 'private_role' => 'items.show_metadata_private', + ], + ], + 'items as application' => [ + 'application', [ + 'model' => Item::class, + 'prefix_url' => 'items', + 'public_role' => 'items.show', + 'private_role' => 'items.show_metadata_private', + ], + ], + + 'orders as user' => [ + 'user', [ + 'model' => Order::class, + 'prefix_url' => 'orders', + 'public_role' => 'orders.show', + 'private_role' => 'orders.show_metadata_private', + ], + ], + 'orders as application' => [ + 'application', [ + 'model' => Order::class, + 'prefix_url' => 'orders', + 'public_role' => 'orders.show', + 'private_role' => 'orders.show_metadata_private', + ], + ], + + 'statuses as user' => [ + 'user', [ + 'model' => Status::class, + 'prefix_url' => 'statuses', + 'public_role' => 'statuses.show', + 'private_role' => 'statuses.show_metadata_private', + ], + ], + 'statuses as application' => [ + 'application', + [ + 'model' => Status::class, + 'prefix_url' => 'statuses', + 'public_role' => 'statuses.show', + 'private_role' => 'statuses.show_metadata_private', + ], + ], + + 'shipping methods as user' => [ + 'user', + [ + 'model' => ShippingMethod::class, + 'prefix_url' => 'shipping-methods', + 'public_role' => 'shipping_methods.show', + 'private_role' => 'shipping_methods.show_metadata_private', + ], + ], + 'shipping methods as application' => [ + 'application', + [ + 'model' => ShippingMethod::class, + 'prefix_url' => 'shipping-methods', + 'public_role' => 'shipping_methods.show', + 'private_role' => 'shipping_methods.show_metadata_private', + ], + ], + + 'package templates as user' => [ + 'user', [ + 'model' => PackageTemplate::class, + 'prefix_url' => 'package-templates', + 'public_role' => 'packages.show', + 'private_role' => 'packages.show_metadata_private', + ], + ], + 'package templates as application' => [ + 'application', + [ + 'model' => PackageTemplate::class, + 'prefix_url' => 'package-templates', + 'public_role' => 'packages.show', + 'private_role' => 'packages.show_metadata_private', + ], + ], + + 'users as user' => [ + 'user', + [ + 'model' => User::class, + 'prefix_url' => 'users', + 'public_role' => 'users.show', + 'private_role' => 'users.show_metadata_private', + ], + ], + 'users as application' => [ + 'application', + [ + 'model' => User::class, + 'prefix_url' => 'users', + 'public_role' => 'users.show', + 'private_role' => 'users.show_metadata_private', + ], + ], + + 'roles as user' => [ + 'user', + [ + 'model' => Role::class, + 'prefix_url' => 'roles', + 'public_role' => 'roles.show', + 'private_role' => 'roles.show_metadata_private', + ], + ], + 'roles as application' => [ + 'application', + [ + 'model' => Role::class, + 'prefix_url' => 'roles', + 'public_role' => 'roles.show', + 'private_role' => 'roles.show_metadata_private', + ], + ], + + 'pages as user' => [ + 'user', + [ + 'model' => Page::class, + 'prefix_url' => 'pages', + 'public_role' => 'pages.show', + 'private_role' => 'pages.show_metadata_private', + ], + ], + 'pages as application' => [ + 'application', + [ + 'model' => Page::class, + 'prefix_url' => 'pages', + 'public_role' => 'pages.show', + 'private_role' => 'pages.show_metadata_private', + ], + ], + + 'apps as user' => [ + 'user', + [ + 'model' => App::class, + 'prefix_url' => 'apps', + 'public_role' => 'apps.show', + 'private_role' => 'apps.show_metadata_private', + ], + ], + 'apps as application' => [ + 'application', + [ + 'model' => App::class, + 'prefix_url' => 'apps', + 'public_role' => 'apps.show', + 'private_role' => 'apps.show_metadata_private', + ], + ], + ]; + } + + /** + * @dataProvider dataProvider + */ + public function testQuery($user, $data): void + { + $this->$user->givePermissionTo($data['public_role']); + + $object = $this->createObjects($data['model']); + + $metadata = $object->first()->metadata()->create([ + 'name' => 'Producent', + 'value' => 'Heseya', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + + $this + ->actingAs($this->$user) + ->getJson("/{$data['prefix_url']}?metadata[{$metadata->name}]={$metadata->value}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment([ + $metadata->name => $metadata->value, + ]) + ->assertJsonMissing([ + 'metadata' => [], + ]); + } + + public function createObjects($model) + { + $objects = $model::factory()->count(3)->create(); + + if ($objects->first()->public !== null) { + $objects->each(fn ($object) => $object->update(['public' => true])); + } + + return $objects; + } + + /** + * @dataProvider authProvider + */ + public function testQueryByMorePropertiesUsingDots($user): void + { + $this->$user->givePermissionTo('orders.show'); + + $objects = $this->createObjects(Order::class); + + $metadata = $objects->first()->metadata()->create([ + 'name' => 'Producent', + 'value' => 'Heseya', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + + $metadata2 = $objects->first()->metadata()->create([ + 'name' => 'Kolor', + 'value' => 'Czerwony', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + + $objects->last()->metadata()->create([ + 'name' => 'Dystrybucja', + 'value' => 'Polska', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + + $name = $metadata->name; + $value = $metadata->value; + + $this + ->actingAs($this->$user) + ->json( + 'GET', + "/orders?metadata.{$name}={$value}&metadata.{$metadata2->name}={$metadata2->value}", + ) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment([ + $metadata->name => $metadata->value, + ]) + ->assertJsonFragment([ + $metadata2->name => $metadata2->value, + ]) + ->assertJsonMissing([ + 'metadata' => [], + ]); + } + + /** + * @dataProvider authProvider + */ + public function testQueryCase1($user): void + { + $this->$user->givePermissionTo('orders.show'); + + $objects = $this->createObjects(Order::class); + + $metadata = $objects->first()->metadata()->create([ + 'name' => 'test1', + 'value' => 0, + 'value_type' => MetadataType::NUMBER, + 'public' => true, + ]); + + $metadata2 = $objects->first()->metadata()->create([ + 'name' => 'test2', + 'value' => 0, + 'value_type' => MetadataType::NUMBER, + 'public' => true, + ]); + + $objects->last()->metadata()->create([ + 'name' => 'test2', + 'value' => 1, + 'value_type' => MetadataType::NUMBER, + 'public' => true, + ]); + + $this + ->actingAs($this->$user) + ->getJson('/orders?metadata.test1=0&metadata.test2=0') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment([ + $metadata->name => $metadata->value, + ]) + ->assertJsonFragment([ + $metadata2->name => $metadata2->value, + ]) + ->assertJsonMissing([ + 'metadata' => [], + ]); + } + + /** + * @dataProvider authProvider + */ + public function testQueryCase2($user): void + { + $this->$user->givePermissionTo('orders.show'); + + $objects = $this->createObjects(Order::class); + + $metadata = $objects->first()->metadata()->create([ + 'name' => 'test1', + 'value' => 0, + 'value_type' => MetadataType::NUMBER, + 'public' => true, + ]); + + $objects->last()->metadata()->create([ + 'name' => 'test1', + 'value' => 1, + 'value_type' => MetadataType::NUMBER, + 'public' => true, + ]); + + $this + ->actingAs($this->$user) + ->getJson('/orders?metadata.test1=0') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment([ + $metadata->name => $metadata->value, + ]) + ->assertJsonMissing([ + 'metadata' => [], + ]); + } + + /** + * @dataProvider authProvider + */ + public function testQueryCase3($user): void + { + $this->$user->givePermissionTo('orders.show'); + + $objects = $this->createObjects(Order::class); + + $metadata = $objects->first()->metadata()->create([ + 'name' => 'test1', + 'value' => 0, + 'value_type' => MetadataType::NUMBER, + 'public' => true, + ]); + + $metadata2 = $objects->first()->metadata()->create([ + 'name' => 'test2', + 'value' => 1, + 'value_type' => MetadataType::NUMBER, + 'public' => true, + ]); + + $metadata3 = $objects->last()->metadata()->create([ + 'name' => 'test1', + 'value' => 1, + 'value_type' => MetadataType::NUMBER, + 'public' => true, + ]); + + $metadata4 = $objects->last()->metadata()->create([ + 'name' => 'test2', + 'value' => 0, + 'value_type' => MetadataType::NUMBER, + 'public' => true, + ]); + + $this + ->actingAs($this->$user) + ->getJson('/orders?metadata.test1=0') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment([ + $metadata->name => $metadata->value, + ]) + ->assertJsonFragment([ + $metadata2->name => $metadata2->value, + ]) + ->assertJsonMissing([ + 'metadata' => [], + ]); + + $this + ->actingAs($this->$user) + ->getJson('/orders?metadata.test1=1') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment([ + $metadata3->name => $metadata3->value, + ]) + ->assertJsonFragment([ + $metadata4->name => $metadata4->value, + ]) + ->assertJsonMissing([ + 'metadata' => [], + ]); + } + + /** + * @dataProvider dataProvider + */ + public function testQueryPrivate($user, $data): void + { + $this->$user->givePermissionTo([$data['public_role'], $data['private_role']]); + + $object = $this->createObjects($data['model']); + + $metadata = $object->first()->metadataPrivate()->create([ + 'name' => 'Producent', + 'value' => 'Heseya - private', + 'value_type' => MetadataType::STRING, + 'public' => false, + ]); + + $this + ->actingAs($this->$user) + ->getJson("/{$data['prefix_url']}?metadata_private[{$metadata->name}]={$metadata->value}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment([ + $metadata->name => $metadata->value, + ]) + ->assertJsonMissing([ + 'metadata_private' => [], + ]); + } + /** + * @dataProvider dataProvider + */ + public function testQueryPrivateWithoutPermission($user, $data): void + { + $this->$user->givePermissionTo($data['public_role']); + + $object = $this->createObjects($data['model']); + + $metadata = $object->first()->metadataPrivate()->create([ + 'name' => 'Producent', + 'value' => 'Heseya - private', + 'value_type' => MetadataType::STRING, + 'public' => false, + ]); + + $this + ->actingAs($this->$user) + ->getJson("/{$data['prefix_url']}?metadata_private[{$metadata->name}]={$metadata->value}") + ->assertOk() + ->assertJsonMissing([ + $metadata->name => $metadata->value, + ]) + ->assertJsonMissing([ + 'metadata_private' => [], + ]); + } +} diff --git a/tests/Feature/MetadataFormsTest.php b/tests/Feature/MetadataFormsTest.php new file mode 100644 index 000000000..d8356d43d --- /dev/null +++ b/tests/Feature/MetadataFormsTest.php @@ -0,0 +1,91 @@ +$user->givePermissionTo('products.add'); + + $response = $this + ->actingAs($this->$user) + ->json('POST', '/products', [ + 'name' => 'Test', + 'slug' => 'test', + 'price' => 100.00, + 'public' => true, + 'metadata' => [ + 'test' => '123', + ], + 'metadata_private' => [ + 'test-two' => 123, + ], + ]); + + $response->assertCreated(); + + $this->assertDatabaseHas('metadata', [ + 'model_id' => $response->getData()->data->id, + 'model_type' => Product::class, + 'name' => 'test', + 'value' => '123', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + + $this->assertDatabaseHas('metadata', [ + 'model_id' => $response->getData()->data->id, + 'model_type' => Product::class, + 'name' => 'test-two', + 'value' => 123, + 'value_type' => MetadataType::NUMBER, + 'public' => false, + ]); + } + +// /** +// * @dataProvider authProvider +// */ +// public function testProductUpdate($user): void +// { +// $this->$user->givePermissionTo('products.edit'); +// +// $product = Product::factory()->create(); +// +// $this +// ->actingAs($this->$user) +// ->json('PATCH', '/products/id:' . $product->getKey(), [ +// 'metadata' => [ +// 'test' => '123', +// ], +// ]) +// ->assertUnprocessable(); +// } +// +// /** +// * @dataProvider authProvider +// */ +// public function testProductPrivateUpdate($user): void +// { +// $this->$user->givePermissionTo('products.edit'); +// +// $product = Product::factory()->create(); +// +// $this +// ->actingAs($this->$user) +// ->json('PATCH', '/products/id:' . $product->getKey(), [ +// 'metadata_private' => [ +// 'test' => '123', +// ], +// ]) +// ->assertUnprocessable(); +// } +} diff --git a/tests/Feature/MetadataTest.php b/tests/Feature/MetadataTest.php new file mode 100644 index 000000000..5a8ba9d6e --- /dev/null +++ b/tests/Feature/MetadataTest.php @@ -0,0 +1,452 @@ + [ + 'user', + ['model' => Product::class, 'prefix_url' => 'products', 'role' => 'products.edit'], + ], + 'products as app' => [ + 'application', + ['model' => Product::class, 'prefix_url' => 'products', 'role' => 'products.edit'], + ], + + 'schemas as user' => [ + 'user', + ['model' => Schema::class, 'prefix_url' => 'schemas', 'role' => 'products.edit'], + ], + 'schemas as application' => [ + 'application', + ['model' => Schema::class, 'prefix_url' => 'schemas', 'role' => 'products.edit'], + ], + + 'options as user' => [ + 'user', + ['model' => Option::class, 'prefix_url' => 'options', 'role' => 'products.edit'], + ], + 'options as application' => [ + 'application', + ['model' => Option::class, 'prefix_url' => 'options', 'role' => 'products.edit'], + ], + + 'product sets as user' => [ + 'user', + ['model' => ProductSet::class, 'prefix_url' => 'product-sets', 'role' => 'product_sets.edit'], + ], + 'product sets as application' => [ + 'application', + ['model' => ProductSet::class, 'prefix_url' => 'product-sets', 'role' => 'product_sets.edit'], + ], + + 'discounts as user' => [ + 'user', + ['model' => Discount::class, 'prefix_url' => 'discounts', 'role' => 'discounts.edit'], + ], + 'discounts as application' => [ + 'application', + ['model' => Discount::class, 'prefix_url' => 'discounts', 'role' => 'discounts.edit'], + ], + + 'items as user' => [ + 'user', + ['model' => Item::class, 'prefix_url' => 'items', 'role' => 'items.edit'], + ], + 'items as application' => [ + 'application', + ['model' => Item::class, 'prefix_url' => 'items', 'role' => 'items.edit'], + ], + + 'orders as user' => [ + 'user', + ['model' => Order::class, 'prefix_url' => 'orders', 'role' => 'orders.edit'], + ], + 'orders as application' => [ + 'application', + ['model' => Order::class, 'prefix_url' => 'orders', 'role' => 'orders.edit'], + ], + + 'statuses as user' => [ + 'user', + ['model' => Status::class, 'prefix_url' => 'statuses', 'role' => 'statuses.edit'], + ], + 'statuses as application' => [ + 'application', + ['model' => Status::class, 'prefix_url' => 'statuses', 'role' => 'statuses.edit'], + ], + + 'shipping methods as user' => [ + 'user', + [ + 'model' => ShippingMethod::class, + 'prefix_url' => 'shipping-methods', + 'role' => 'shipping_methods.edit', + ], + ], + 'shipping methods as application' => [ + 'application', + ['model' => ShippingMethod::class, + 'prefix_url' => 'shipping-methods', + 'role' => 'shipping_methods.edit', + ], + ], + + 'package templates as user' => [ + 'user', [ + 'model' => PackageTemplate::class, + 'prefix_url' => 'package-templates', + 'role' => 'packages.edit', + ], + ], + 'package templates as application' => [ + 'application', [ + 'model' => PackageTemplate::class, + 'prefix_url' => 'package-templates', + 'role' => 'packages.edit', + ], + ], + + 'users as user' => [ + 'user', + ['model' => User::class, 'prefix_url' => 'users', 'role' => 'users.edit'], + ], + 'users as application' => [ + 'application', + ['model' => User::class, 'prefix_url' => 'users', 'role' => 'users.edit'], + ], + + 'roles as user' => [ + 'user', + ['model' => Role::class, 'prefix_url' => 'roles', 'role' => 'roles.edit'], + ], + 'roles as application' => [ + 'application', + ['model' => Role::class, 'prefix_url' => 'roles', 'role' => 'roles.edit'], + ], + + 'pages as user' => [ + 'user', + ['model' => Page::class, 'prefix_url' => 'pages', 'role' => 'pages.edit'], + ], + 'pages as application' => [ + 'application', + ['model' => Page::class, 'prefix_url' => 'pages', 'role' => 'pages.edit'], + ], + + 'apps as user' => [ + 'user', + ['model' => App::class, 'prefix_url' => 'apps', 'role' => 'apps.install'], + ], + 'apps as application' => [ + 'application', + ['model' => App::class, 'prefix_url' => 'apps', 'role' => 'apps.install'], + ], + + 'media as user' => [ + 'user', + ['model' => Media::class, 'prefix_url' => 'media', 'role' => 'products.edit'], + ], + 'media as application' => [ + 'application', + ['model' => Media::class, 'prefix_url' => 'media', 'role' => 'products.edit'], + ], + ]; + } + + /** + * @dataProvider dataProvider + */ + public function testAddMetadata($user, $data): void + { + $this->$user->givePermissionTo($data['role']); + + $related = []; + + if ($data['model'] === Option::class) { + $related = [ + 'schema_id' => (Schema::factory()->create())->getKey(), + ]; + } + + $object = $data['model']::factory()->create($related); + + $metadata = [ + 'sample text metadata' => 'Lorem ipsum dolor sit amet', + 'sample numeric metadata' => 21.5, + 'sample bool metadata' => true, + ]; + + $this->actingAs($this->$user)->patchJson( + "/{$data['prefix_url']}/id:{$object->getKey()}/metadata", + $metadata + ) + ->assertOk() + ->assertJsonFragment(['data' => $metadata]); + } + + /** + * @dataProvider dataProvider + */ + public function testAddMetadataPrivate($user, $data): void + { + $this->$user->givePermissionTo($data['role']); + + $related = []; + + if ($data['model'] === Option::class) { + $related = [ + 'schema_id' => (Schema::factory()->create())->getKey(), + ]; + } + + $object = $data['model']::factory()->create($related); + + $metadata = [ + 'sample text metadata private' => 'Aliquam porta viverra tortor non faucibus', + 'sample numeric metadata private' => 22.5, + 'sample bool metadata private' => true, + ]; + + $this->actingAs($this->$user)->patchJson( + "/{$data['prefix_url']}/id:{$object->getKey()}/metadata-private", + $metadata + ) + ->assertOk() + ->assertJsonFragment(['data' => $metadata]); + } + + /** + * @dataProvider dataProvider + */ + public function testUpdateMetadata($user, $data): void + { + $this->$user->givePermissionTo($data['role']); + + $related = []; + + if ($data['model'] === Option::class) { + $related = [ + 'schema_id' => (Schema::factory()->create())->getKey(), + ]; + } + + $object = $data['model']::factory()->create($related); + + $metadata = $object->metadata()->create([ + 'name' => 'Metadata', + 'value' => 'metadata test', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + + $this->actingAs($this->$user)->patchJson( + "/{$data['prefix_url']}/id:{$object->getKey()}/metadata", + [ + $metadata->name => 'new super value', + ] + ) + ->assertOk() + ->assertJsonFragment(['data' => [ + $metadata->name => 'new super value', + ], + ]); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateMetadataSameKeys($user): void + { + $this->$user->givePermissionTo('products.edit'); + + $product = Product::factory()->create(); + + $metadata = $product->metadata()->create([ + 'name' => 'Metadata', + 'value' => 'metadata test', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + + $product2 = Product::factory()->create(); + + $metadata2 = $product2->metadata()->create([ + 'name' => 'Metadata', + 'value' => 'metadata test', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + + $this->actingAs($this->$user)->patchJson( + "/products/id:{$product->getKey()}/metadata", + [ + $metadata->name => 'new super value', + ] + ) + ->assertOk() + ->assertJsonFragment(['data' => [ + $metadata->name => 'new super value', + ], + ]); + + $this->assertDatabaseHas('metadata', array_merge($metadata2->toArray(), [ + 'model_id' => $product2->getKey(), + 'model_type' => Product::class, + ])); + } + + /** + * @dataProvider dataProvider + */ + public function testUpdateMetadataPrivate($user, $data): void + { + $this->$user->givePermissionTo($data['role']); + + $related = []; + + if ($data['model'] === Option::class) { + $related = [ + 'schema_id' => (Schema::factory()->create())->getKey(), + ]; + } + + $object = $data['model']::factory()->create($related); + + $metadata = $object->metadataPrivate()->create([ + 'name' => 'Metadata', + 'value' => 'metadata test', + 'value_type' => MetadataType::STRING, + 'public' => false, + ]); + + $this->actingAs($this->$user)->patchJson( + "/{$data['prefix_url']}/id:{$object->getKey()}/metadata-private", + [ + $metadata->name => 'new super value', + ] + ) + ->assertOk() + ->assertJsonFragment(['data' => [ + $metadata->name => 'new super value', + ], + ]); + } + + /** + * @dataProvider dataProvider + */ + public function testDeleteMetadata($user, $data): void + { + $this->$user->givePermissionTo($data['role']); + + $related = []; + + if ($data['model'] === Option::class) { + $related = [ + 'schema_id' => (Schema::factory()->create())->getKey(), + ]; + } + + $object = $data['model']::factory()->create($related); + + $metadata1 = $object->metadata()->create([ + 'name' => 'Metadata', + 'value' => 'metadata test', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + + $metadata2 = $object->metadata()->create([ + 'name' => 'Metadata2', + 'value' => 'metadata2 test', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + + $this->actingAs($this->$user)->patchJson( + "/{$data['prefix_url']}/id:{$object->getKey()}/metadata", + [ + $metadata1->name => $metadata1->value, + $metadata2->name => null, + ] + ) + ->assertOk() + ->assertJsonFragment(['data' => [ + $metadata1->name => $metadata1->value, + ], + ]) + ->assertJsonMissing([ + $metadata2->name => $metadata2->value, + ]); + } + + /** + * @dataProvider dataProvider + */ + public function testDeleteMetadataPrivate($user, $data): void + { + $this->$user->givePermissionTo($data['role']); + + $related = []; + + if ($data['model'] === Option::class) { + $related = [ + 'schema_id' => (Schema::factory()->create())->getKey(), + ]; + } + + $object = $data['model']::factory()->create($related); + + $metadata1 = $object->metadataPrivate()->create([ + 'name' => 'Metadata', + 'value' => 'metadata test', + 'value_type' => MetadataType::STRING, + 'public' => false, + ]); + + $metadata2 = $object->metadataPrivate()->create([ + 'name' => 'Metadata2 private', + 'value' => 'metadata2 test private', + 'value_type' => MetadataType::STRING, + 'public' => false, + ]); + + $this->actingAs($this->$user)->patchJson( + "/{$data['prefix_url']}/id:{$object->getKey()}/metadata-private", + [ + $metadata1->name => $metadata1->value, + $metadata2->name => null, + ] + ) + ->assertOk() + ->assertJsonFragment(['data' => [ + $metadata1->name => $metadata1->value, + ], + ]) + ->assertJsonMissing([ + $metadata2->name => $metadata2->value, + ]); + } +} diff --git a/tests/Feature/OrderTest.php b/tests/Feature/OrderTest.php index 4ff7fa0fd..bb5213492 100644 --- a/tests/Feature/OrderTest.php +++ b/tests/Feature/OrderTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Enums\MetadataType; use App\Events\ItemUpdatedQuantity; use App\Events\OrderCreated; use App\Events\OrderUpdatedStatus; @@ -60,6 +61,13 @@ public function setUp(): void 'price' => 49.99, ]); + $this->order->metadata()->create([ + 'name' => 'Metadata', + 'value' => 'metadata test', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + /** @var OrderService $orderService */ $orderService = App::make(OrderServiceContract::class); @@ -96,6 +104,7 @@ public function setUp(): void 'paid', 'created_at', 'shipping_method', + 'metadata', ]; $this->expected_full_view_structure = $this->expected_full_structure + ['user']; @@ -517,6 +526,32 @@ public function testView($user): void ->assertJsonStructure(['data' => $this->expected_full_view_structure]); } + /** + * @dataProvider authProvider + */ + public function testViewPrivateMetadata($user): void + { + $this->$user->givePermissionTo(['orders.show_details', 'orders.show_metadata_private']); + + $privateMetadata = $this->order->metadataPrivate()->create([ + 'name' => 'hiddenMetadata', + 'value' => 'hidden metadata test', + 'value_type' => MetadataType::STRING, + 'public' => false, + ]); + + $response = $this->actingAs($this->$user) + ->getJson('/orders/id:' . $this->order->getKey()); + $response + ->assertOk() + ->assertJsonFragment(['code' => $this->order->code]) + ->assertJsonStructure(['data' => $this->expected_full_view_structure]) + ->assertJsonFragment(['metadata_private' => [ + $privateMetadata->name => $privateMetadata->value, + ], + ]); + } + public function testViewSummaryUnauthorized(): void { $response = $this->getJson('/orders/' . $this->order->code); diff --git a/tests/Feature/OrderUpdateTest.php b/tests/Feature/OrderUpdateTest.php index 5e7341f34..4f1436268 100644 --- a/tests/Feature/OrderUpdateTest.php +++ b/tests/Feature/OrderUpdateTest.php @@ -92,6 +92,7 @@ public function testFullUpdateOrder($user): void 'name' => $this->status->name, 'hidden' => $this->status->hidden, 'no_notifications' => $this->status->no_notifications, + 'metadata' => [], ], 'delivery_address' => [ 'id' => $responseData->delivery_address->id, @@ -181,6 +182,7 @@ public function testFullUpdateOrderWithWebHookQueue($user): void 'name' => $this->status->name, 'hidden' => $this->status->hidden, 'no_notifications' => $this->status->no_notifications, + 'metadata' => [], ], 'delivery_address' => [ 'id' => $responseData->delivery_address->id, @@ -270,6 +272,7 @@ public function testFullUpdateOrderWithWebHookDispatched($user): void 'name' => $this->status->name, 'hidden' => $this->status->hidden, 'no_notifications' => $this->status->no_notifications, + 'metadata' => [], ], 'delivery_address' => [ 'id' => $responseData->delivery_address->id, @@ -351,6 +354,7 @@ public function testUpdateOrderByEmail($user): void 'name' => $this->status->name, 'hidden' => $this->status->hidden, 'no_notifications' => $this->status->no_notifications, + 'metadata' => [], ], ]); @@ -395,6 +399,7 @@ public function testUpdateOrderByComment($user): void 'name' => $this->status->name, 'hidden' => $this->status->hidden, 'no_notifications' => $this->status->no_notifications, + 'metadata' => [], ], ]); @@ -468,6 +473,7 @@ public function testUpdateOrderWithDeliveryAddress($user): void 'name' => $this->status->name, 'hidden' => $this->status->hidden, 'no_notifications' => $this->status->no_notifications, + 'metadata' => [], ], 'delivery_address' => [ 'address' => $this->addressDelivery->address, @@ -591,6 +597,7 @@ public function testUpdateOrderByInvoiceAddress($user): void 'name' => $this->status->name, 'hidden' => $this->status->hidden, 'no_notifications' => $this->status->no_notifications, + 'metadata' => [], ], 'invoice_address' => [ 'address' => $this->addressInvoice->address, diff --git a/tests/Feature/PackageTemplateTest.php b/tests/Feature/PackageTemplateTest.php index 76a2ed32a..df2b4680d 100644 --- a/tests/Feature/PackageTemplateTest.php +++ b/tests/Feature/PackageTemplateTest.php @@ -27,6 +27,7 @@ public function setUp(): void 'width' => $this->package->width, 'height' => $this->package->height, 'depth' => $this->package->depth, + 'metadata' => [], ]; } diff --git a/tests/Feature/PageTest.php b/tests/Feature/PageTest.php index 491d2e1d6..be292a186 100644 --- a/tests/Feature/PageTest.php +++ b/tests/Feature/PageTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Enums\MetadataType; use App\Events\PageCreated; use App\Events\PageDeleted; use App\Events\PageUpdated; @@ -40,6 +41,13 @@ public function setUp(): void 'public' => false, ]); + $metadata = $this->page->metadata()->create([ + 'name' => 'Metadata', + 'value' => 'metadata test', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + /** * Expected response */ @@ -48,10 +56,14 @@ public function setUp(): void 'name' => $this->page->name, 'slug' => $this->page->slug, 'public' => $this->page->public, + 'metadata' => [], ]; $this->expected_view = array_merge($this->expected, [ 'content_html' => $this->page->content_html, + 'metadata' => [ + $metadata->name => $metadata->value, + ], ]); } @@ -78,7 +90,7 @@ public function testIndex($user): void ], ]); - $this->assertQueryCountLessThan(10); + $this->assertQueryCountLessThan(11); } /** @@ -96,7 +108,7 @@ public function testIndexPerformance($user): void ->assertOk() ->assertJsonCount(500, 'data'); - $this->assertQueryCountLessThan(10); + $this->assertQueryCountLessThan(11); } /** @@ -141,6 +153,38 @@ public function testView($user): void ->assertJson(['data' => $this->expected_view]); } + /** + * @dataProvider authProvider + */ + public function testViewPrivateMetadata($user): void + { + $this->$user->givePermissionTo(['pages.show_details', 'pages.show_metadata_private']); + + $privateMetadata = $this->page->metadataPrivate()->create([ + 'name' => 'hiddenMetadata', + 'value' => 'hidden metadata test', + 'value_type' => MetadataType::STRING, + 'public' => false, + ]); + + $response = $this->actingAs($this->$user) + ->getJson('/pages/' . $this->page->slug); + $response + ->assertOk() + ->assertJson(['data' => $this->expected_view]); + + $response = $this->actingAs($this->$user) + ->getJson('/pages/id:' . $this->page->getKey()); + $response + ->assertOk() + ->assertJson(['data' => $this->expected_view + + ['metadata_private' => [ + $privateMetadata->name => $privateMetadata->value, + ], + ], + ]); + } + /** * @dataProvider authProvider */ diff --git a/tests/Feature/ProductSearchTest.php b/tests/Feature/ProductSearchTest.php index bc4a6fa3e..3a106f46b 100644 --- a/tests/Feature/ProductSearchTest.php +++ b/tests/Feature/ProductSearchTest.php @@ -6,14 +6,11 @@ use App\Models\ProductSet; use App\Models\Tag; use Illuminate\Contracts\Auth\Authenticatable; -use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Testing\TestResponse; use Tests\TestCase; class ProductSearchTest extends TestCase { - use RefreshDatabase; - /** * @dataProvider authProvider */ @@ -25,12 +22,36 @@ public function testSearch($user): void 'public' => true, ]); - $response = $this->actingAs($this->$user) - ->getJson('/products?search=' . $product->name); - $response - ->assertOk() - ->assertJsonCount(1, 'data') - ->assertJsonFragment(['id' => $product->getKey()]); + $this + ->actingAs($this->$user) + ->json('GET', '/products', ['search' => $product->name]) + ->assertOk(); +// ->assertJsonCount(1, 'data') +// ->assertJsonFragment(['id' => $product->getKey()]); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [ + [ + 'multi_match' => [ + 'query' => $product->name, + 'fuzziness' => 'auto', + ], + ], + ], + 'should' => [], + 'filter' => [ + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); } /** @@ -56,13 +77,37 @@ public function testSearchBySet($user): void $set->products()->attach($product); - $response = $this->actingAs($this->$user) - ->getJson('/products?sets[]=' . $set->slug); - - $response - ->assertOk() - ->assertJsonCount(1, 'data') - ->assertJsonFragment(['id' => $product->getKey()]); + $this + ->actingAs($this->$user) + ->json('GET', '/products', ['sets' => [$set->slug]]) + ->assertOk(); +// ->assertJsonCount(1, 'data') +// ->assertJsonFragment(['id' => $product->getKey()]); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'terms' => [ + 'sets.slug' => [ + $set->slug, + ], + 'boost' => 1.0, + ], + ], + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); } /** @@ -96,13 +141,41 @@ public function testSearchBySets($user): void $set->products()->attach($product); $set2->products()->attach($product2); - $response = $this->actingAs($this->$user) - ->getJson('/products?sets[]=' . $set->slug . '&sets[]=' . $set2->slug); - $response - ->assertOk() - ->assertJsonCount(2, 'data') - ->assertJsonFragment(['id' => $product->getKey()]) - ->assertJsonFragment(['id' => $product2->getKey()]); + $this + ->actingAs($this->$user) + ->json('GET', '/products', [ + 'sets' => [$set->slug, $set2->slug], + ]) + ->assertOk(); +// ->assertJsonCount(2, 'data') +// ->assertJsonFragment(['id' => $product->getKey()]) +// ->assertJsonFragment(['id' => $product2->getKey()]); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'terms' => [ + 'sets.slug' => [ + $set->slug, + $set2->slug, + ], + 'boost' => 1.0, + ], + ], + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); } /** @@ -127,10 +200,10 @@ public function testSearchBySetHiddenUnauthorized($user): void $set->products()->attach($product); - $response = $this->actingAs($this->$user) - ->getJson('/products?sets[]=' . $set->slug); - - $response->assertUnprocessable(); + $this + ->actingAs($this->$user) + ->json('GET', '/products', ['sets' => [$set->slug]]) + ->assertUnprocessable(); } /** @@ -160,13 +233,37 @@ public function testSearchBySetHidden($user): void $set->products()->attach($product); - $response = $this->actingAs($this->$user) - ->getJson('/products?sets[]=' . $set->slug); - - $response - ->assertOk() - ->assertJsonCount(1, 'data') - ->assertJsonFragment(['id' => $product->getKey()]); + $this + ->actingAs($this->$user) + ->json('GET', '/products', ['sets' => [$set->slug]]) + ->assertOk(); +// ->assertJsonCount(1, 'data') +// ->assertJsonFragment(['id' => $product->getKey()]); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'terms' => [ + 'sets.slug' => [ + $set->slug, + ], + 'boost' => 1.0, + ], + ], + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); } /** @@ -176,10 +273,11 @@ public function testSearchByParentSet($user): void { $this->$user->givePermissionTo('products.show'); - $this->getProductsByParentSet($this->$user, true, $product) - ->assertOk() - ->assertJsonCount(1, 'data') - ->assertJsonFragment(['id' => $product->getKey()]); + $this + ->getProductsByParentSet($this->$user, true, $product) + ->assertOk(); +// ->assertJsonCount(1, 'data') +// ->assertJsonFragment(['id' => $product->getKey()]); } /** @@ -189,9 +287,10 @@ public function testSearchByParentSetWithPrivateChildUnauthorized($user): void { $this->$user->givePermissionTo('products.show'); - $this->getProductsByParentSet($this->$user, false) - ->assertOk() - ->assertJsonCount(0, 'data'); + $this + ->getProductsByParentSet($this->$user, false); +// ->assertOk() +// ->assertJsonCount(0, 'data'); } /** @@ -204,10 +303,11 @@ public function testSearchByParentSetWithPrivateChild($user): void 'product_sets.show_hidden', ]); - $this->getProductsByParentSet($this->$user, false, $product) - ->assertOk() - ->assertJsonCount(1, 'data') - ->assertJsonFragment(['id' => $product->getKey()]); + $this + ->getProductsByParentSet($this->$user, false, $product) + ->assertOk(); +// ->assertJsonCount(1, 'data') +// ->assertJsonFragment(['id' => $product->getKey()]); } /** @@ -230,11 +330,37 @@ public function testSearchByTag($user): void $tag->products()->attach($product); - $this->actingAs($this->$user) + $this + ->actingAs($this->$user) ->json('GET', '/products', ['tags' => [$tag->getKey()]]) - ->assertOk() - ->assertJsonCount(1, 'data') - ->assertJsonFragment(['id' => $product->getKey()]); + ->assertOk(); +// ->assertJsonCount(1, 'data') +// ->assertJsonFragment(['id' => $product->getKey()]); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'terms' => [ + 'tags.id' => [ + $tag->getKey(), + ], + 'boost' => 1.0, + ], + ], + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); } /** @@ -271,10 +397,191 @@ public function testSearchByTags($user): void $tag2->getKey(), ], ]) - ->assertOk() - ->assertJsonCount(2, 'data') - ->assertJsonFragment(['id' => $product1->getKey()]) - ->assertJsonFragment(['id' => $product2->getKey()]); + ->assertOk(); +// ->assertJsonCount(2, 'data') +// ->assertJsonFragment(['id' => $product1->getKey()]) +// ->assertJsonFragment(['id' => $product2->getKey()]); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'terms' => [ + 'tags.id' => [ + $tag1->getKey(), + $tag2->getKey(), + ], + 'boost' => 1.0, + ], + ], + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); + } + + /** + * @dataProvider authProvider + */ + public function testSearchByMetadata($user): void + { + $this->$user->givePermissionTo('products.show'); + + $this + ->actingAs($this->$user) + ->json('GET', '/products?metadata.erp_id=1000&metadata.sku=S001') + ->assertOk(); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'terms' => [ + 'metadata.name' => [ + 'erp_id', + 'sku', + ], + 'boost' => 1.0, + ], + ], + [ + 'terms' => [ + 'metadata.value' => [ + 1000, + 'S001', + ], + 'boost' => 1.0, + ], + ], + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); + } + + /** + * @dataProvider authProvider + */ + public function testSearchByMetadataClassicArray($user): void + { + $this->$user->givePermissionTo('products.show'); + + $this + ->actingAs($this->$user) + ->json('GET', '/products?metadata[erp_id]=1000&metadata[sku]=S001') + ->assertOk(); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'terms' => [ + 'metadata.name' => [ + 'erp_id', + 'sku', + ], + 'boost' => 1.0, + ], + ], + [ + 'terms' => [ + 'metadata.value' => [ + 1000, + 'S001', + ], + 'boost' => 1.0, + ], + ], + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); + } + + /** + * @dataProvider authProvider + */ + public function testSearchByMetadataPrivate($user): void + { + $this->$user->givePermissionTo(['products.show', 'products.show_metadata_private']); + + $this + ->actingAs($this->$user) + ->json('GET', '/products?metadata_private.sku=S001') + ->assertOk(); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'terms' => [ + 'metadata_private.name' => [ + 'sku', + ], + 'boost' => 1.0, + ], + ], + [ + 'terms' => [ + 'metadata_private.value' => [ + 'S001', + ], + 'boost' => 1.0, + ], + ], + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); + } + + /** + * @dataProvider authProvider + */ + public function testSearchByMetadataPrivateUnauthorized($user): void + { + $this->$user->givePermissionTo('products.show'); + + $this + ->actingAs($this->$user) + ->json('GET', '/products?metadata_private.sku=S001') + ->assertUnprocessable(); } private function getProductsByParentSet( @@ -302,7 +609,35 @@ private function getProductsByParentSet( $childSet->products()->attach($productRef); - return $this->actingAs($user) - ->getJson('/products?sets[]=' . $parentSet->slug); + $request = $this + ->actingAs($user) + ->getJson("/products?sets[]={$parentSet->slug}"); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'terms' => [ + 'sets.slug' => [ + $parentSet->slug, + ], + 'boost' => 1.0, + ], + ], + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); + + return $request; } } diff --git a/tests/Feature/ProductSetCreateTest.php b/tests/Feature/ProductSetCreateTest.php index 1619c61a7..295c791ef 100644 --- a/tests/Feature/ProductSetCreateTest.php +++ b/tests/Feature/ProductSetCreateTest.php @@ -5,6 +5,7 @@ use App\Enums\MediaType; use App\Events\ProductSetCreated; use App\Listeners\WebHookEventListener; +use App\Models\Attribute; use App\Models\Media; use App\Models\ProductSet; use App\Models\WebHook; @@ -640,4 +641,64 @@ public function testCreateWithSeo($user): void 'slug' => 'test', ]); } + + /** + * @dataProvider authProvider + */ + public function testCreateWithAttributes($user): void + { + $this->$user->givePermissionTo('product_sets.add'); + + Event::fake([ProductSetCreated::class]); + + $set = [ + 'name' => 'Test', + ]; + + $defaults = [ + 'public' => true, + 'hide_on_index' => false, + 'slug' => 'test', + ]; + + $attrOne = Attribute::factory()->create(); + $attrTwo = Attribute::factory()->create(); + + $response = $this->actingAs($this->$user)->postJson('/product-sets', $set + [ + 'slug_suffix' => 'test', + 'slug_override' => false, + 'attributes' => [ + $attrOne->getKey(), + $attrTwo->getKey(), + ], + ]); + + $response + ->assertCreated() + ->assertJson(['data' => $set + $defaults + [ + 'slug_override' => false, + 'slug_suffix' => 'test', + 'parent' => null, + ], + ]); + + $productSet = ProductSet::find($response->getData()->data->id); + + $this->assertDatabaseHas('product_sets', $set + $defaults + [ + 'parent_id' => null, + ]) + ->assertDatabaseHas('attribute_product_set', [ + 'attribute_id' => $attrOne->getKey(), + 'product_set_id' => $productSet->getKey(), + ]) + ->assertDatabaseHas('attribute_product_set', [ + 'attribute_id' => $attrTwo->getKey(), + 'product_set_id' => $productSet->getKey(), + ]); + + $this->assertTrue($productSet->attributes->contains($attrOne)); + $this->assertTrue($productSet->attributes->contains($attrTwo)); + + Event::assertDispatched(ProductSetCreated::class); + } } diff --git a/tests/Feature/ProductSetShowTest.php b/tests/Feature/ProductSetShowTest.php index c19541296..feac8eae3 100644 --- a/tests/Feature/ProductSetShowTest.php +++ b/tests/Feature/ProductSetShowTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Models\Attribute; use App\Models\ProductSet; use App\Models\SeoMetadata; use Tests\TestCase; @@ -435,4 +436,58 @@ public function testShowSlugTreeHidden($user): void 'data' => $this->expected_structure, ]); } + + /** + * @dataProvider authProvider + */ + public function testProductSetHasAttributes($user): void + { + $this->$user->givePermissionTo(['product_sets.show_details', 'product_sets.show_hidden']); + + $firstAttr = Attribute::factory()->create([ + 'name' => 'test', + 'description' => 'test', + 'type' => 'number', + 'global' => false, + ]); + $secondAttr = Attribute::factory()->create([ + 'name' => 'test2', + 'description' => 'test2', + 'type' => 'number', + 'global' => false, + ]); + + $this->set->attributes()->attach([ + $firstAttr->getKey(), + $secondAttr->getKey(), + ]); + + $response = $this->actingAs($this->$user) + ->getJson('/product-sets/id:' . $this->set->getKey()); + + $response + ->assertOk() + ->assertJson(['data' => [ + 'id' => $this->set->getKey(), + 'name' => $this->set->name, + 'slug' => $this->set->slug, + 'slug_override' => false, + 'public' => $this->set->public, + 'visible' => $this->set->public && $this->set->public_parent, + 'hide_on_index' => $this->set->hide_on_index, + 'parent' => $this->set->parent, + 'children_ids' => [ + $this->childSet->getKey(), + ], + 'seo' => [ + 'title' => $this->set->seo->title, + 'description' => $this->set->seo->description, + ], + ], + ]) + ->assertJsonStructure([ + 'data' => array_merge($this->expected_structure, ['attributes']), + ]) + ->assertJsonCount(2, 'data.attributes'); + } } diff --git a/tests/Feature/ProductSetUpdateTest.php b/tests/Feature/ProductSetUpdateTest.php index 254e8a120..53b7c2105 100644 --- a/tests/Feature/ProductSetUpdateTest.php +++ b/tests/Feature/ProductSetUpdateTest.php @@ -4,6 +4,7 @@ use App\Events\ProductSetUpdated; use App\Listeners\WebHookEventListener; +use App\Models\Attribute; use App\Models\ProductSet; use App\Models\SeoMetadata; use App\Models\WebHook; @@ -396,4 +397,184 @@ public function testUpdateWithSeo($user): void 'description' => 'seo description', ]); } + + /** + * @dataProvider authProvider + */ + public function testUpdateWithAttributes($user): void + { + $this->$user->givePermissionTo('product_sets.edit'); + + Event::fake([ProductSetUpdated::class]); + + $newSet = ProductSet::factory()->create([ + 'public' => false, + 'order' => 40, + ]); + + $attrOne = Attribute::factory()->create(); + $attrTwo = Attribute::factory()->create(); + $attrThree = Attribute::factory()->create(); + + $newSet->attributes()->sync($attrOne->getKey()); + + $set = [ + 'name' => 'Test Edit', + 'public' => true, + 'hide_on_index' => true, + ]; + + $parentId = [ + 'parent_id' => null, + ]; + + $response = $this->actingAs($this->$user)->patchJson( + '/product-sets/id:' . $newSet->getKey(), + $set + $parentId + [ + 'children_ids' => [], + 'slug_suffix' => 'test-edit', + 'slug_override' => false, + 'attributes' => [ + $attrTwo->getKey(), + $attrThree->getKey(), + ], + ], + ); + + $response + ->assertOk() + ->assertJson(['data' => $set + [ + 'parent' => null, + 'children_ids' => [], + 'slug' => 'test-edit', + 'slug_suffix' => 'test-edit', + 'slug_override' => false, + ], + ]); + + $this->assertDatabaseHas('product_sets', $set + $parentId + [ + 'slug' => 'test-edit', + ]); + + $this->assertTrue(!$newSet->attributes->contains($attrOne)); + $this->assertTrue($newSet->attributes->contains($attrTwo)); + $this->assertTrue($newSet->attributes->contains($attrThree)); + + Event::assertDispatched(ProductSetUpdated::class); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateWithEmptyAttributes($user): void + { + $this->$user->givePermissionTo('product_sets.edit'); + + Event::fake([ProductSetUpdated::class]); + + $newSet = ProductSet::factory()->create([ + 'public' => false, + 'order' => 40, + ]); + + $attrOne = Attribute::factory()->create(); + + $newSet->attributes()->sync($attrOne->getKey()); + + $set = [ + 'name' => 'Test Edit', + 'public' => true, + 'hide_on_index' => true, + ]; + + $parentId = [ + 'parent_id' => null, + ]; + + $response = $this->actingAs($this->$user)->patchJson( + '/product-sets/id:' . $newSet->getKey(), + $set + $parentId + [ + 'children_ids' => [], + 'slug_suffix' => 'test-edit', + 'slug_override' => false, + 'attributes' => [], + ], + ); + + $response + ->assertOk() + ->assertJson(['data' => $set + [ + 'parent' => null, + 'children_ids' => [], + 'slug' => 'test-edit', + 'slug_suffix' => 'test-edit', + 'slug_override' => false, + ], + ]); + + $this->assertDatabaseHas('product_sets', $set + $parentId + [ + 'slug' => 'test-edit', + ]); + + $this->assertTrue(!$newSet->attributes->contains($attrOne)); + + Event::assertDispatched(ProductSetUpdated::class); + } + + /** + * @dataProvider authProvider + */ + public function testUpdateWithoutAttributes($user): void + { + $this->$user->givePermissionTo('product_sets.edit'); + + Event::fake([ProductSetUpdated::class]); + + $newSet = ProductSet::factory()->create([ + 'public' => false, + 'order' => 40, + ]); + + $attrOne = Attribute::factory()->create(); + + $newSet->attributes()->sync($attrOne->getKey()); + + $set = [ + 'name' => 'Test Edit', + 'public' => true, + 'hide_on_index' => true, + ]; + + $parentId = [ + 'parent_id' => null, + ]; + + $response = $this->actingAs($this->$user)->patchJson( + '/product-sets/id:' . $newSet->getKey(), + $set + $parentId + [ + 'children_ids' => [], + 'slug_suffix' => 'test-edit', + 'slug_override' => false, + ], + ); + + $response + ->assertOk() + ->assertJson(['data' => $set + [ + 'parent' => null, + 'children_ids' => [], + 'slug' => 'test-edit', + 'slug_suffix' => 'test-edit', + 'slug_override' => false, + ], + ]); + + $this->assertDatabaseHas('product_sets', $set + $parentId + [ + 'slug' => 'test-edit', + ]); + + $this->assertTrue($newSet->attributes->contains($attrOne)); + + Event::assertDispatched(ProductSetUpdated::class); + } } diff --git a/tests/Feature/ProductTest.php b/tests/Feature/ProductTest.php index 68b6f2ad9..ddf7196bd 100644 --- a/tests/Feature/ProductTest.php +++ b/tests/Feature/ProductTest.php @@ -2,14 +2,19 @@ namespace Tests\Feature; +use App\Enums\AttributeType; use App\Enums\MediaType; +use App\Enums\MetadataType; use App\Enums\SchemaType; use App\Events\ProductCreated; use App\Events\ProductDeleted; use App\Events\ProductUpdated; use App\Listeners\WebHookEventListener; +use App\Models\Attribute; +use App\Models\AttributeOption; use App\Models\Media; use App\Models\Product; +use App\Models\ProductAttribute; use App\Models\ProductSet; use App\Models\Schema; use App\Models\SeoMetadata; @@ -25,15 +30,20 @@ use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Spatie\WebhookServer\CallWebhookJob; +use Tests\Support\ElasticTest; use Tests\TestCase; class ProductTest extends TestCase { + use ElasticTest; + private Product $product; private Product $hidden_product; private array $expected; private array $expected_short; + private array $expected_attribute; + private array $expected_attribute_short; private ProductServiceContract $productService; private AvailabilityServiceContract $availabilityService; @@ -85,10 +95,28 @@ public function setUp(): void 'quantity' => 10, ]); + $metadata = $this->product->metadata()->create([ + 'name' => 'testMetadata', + 'value' => 'value metadata', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + $this->hidden_product = Product::factory()->create([ 'public' => false, ]); + $attribute = Attribute::factory()->create(); + + $option = AttributeOption::factory()->create([ + 'index' => 1, + 'attribute_id' => $attribute->getKey(), + ]); + + $this->product->attributes()->attach($attribute->getKey()); + + $this->product->attributes->first()->pivot->options()->attach($option->getKey()); + $this->availabilityService->calculateAvailabilityOnOrderAndRestock($item); /** @@ -105,10 +133,38 @@ public function setUp(): void 'cover' => null, ]; + $this->expected_attribute_short = [ + 'attributes' => [ + [ + 'name' => $attribute->name, + 'selected_options' => [ + [ + 'id' => $option->getKey(), + 'name' => $option->name, + 'index' => $option->index, + 'value_number' => $option->value_number, + 'value_date' => $option->value_date, + 'attribute_id' => $attribute->getKey(), + ], + ], + ], + ], + ]; + + $this->expected_attribute = $this->expected_attribute_short; + $this->expected_attribute['attributes'][0] += [ + 'id' => $attribute->getKey(), + 'slug' => $attribute->slug, + 'description' => $attribute->description, + 'type' => $attribute->type, + 'global' => $attribute->global, + 'sortable' => $attribute->sortable, + ]; + /** * Expected full response */ - $this->expected = array_merge($this->expected_short, [ + $this->expected = array_merge($this->expected_short, $this->expected_attribute, [ 'description_html' => $this->product->description_html, 'description_short' => $this->product->description_short, 'meta_description' => strip_tags($this->product->description_html), @@ -119,6 +175,7 @@ public function setUp(): void 'required' => true, 'available' => true, 'price' => 0, + 'metadata' => [], 'options' => [ [ 'name' => 'XL', @@ -130,6 +187,7 @@ public function setUp(): void 'sku' => 'K001/XL', ], ], + 'metadata' => [], ], [ 'name' => 'L', @@ -141,10 +199,14 @@ public function setUp(): void 'sku' => 'K001/L', ], ], + 'metadata' => [], ], ], ], ], + 'metadata' => [ + $metadata->name => $metadata->value, + ], ]); } @@ -169,14 +231,39 @@ public function testIndex($user): void ]); $product->sets()->sync([$set->getKey()]); - $response = $this->actingAs($this->$user)->getJson('/products?limit=100'); - $response - ->assertOk() - ->assertJsonCount(1, 'data') // Should show only public products. - ->assertJson(['data' => [ - 0 => $this->expected_short, + $this + ->actingAs($this->$user) + ->json('GET', '/products', ['limit' => 100]) + ->assertOk(); +// ->assertJsonCount(1, 'data') // Should show only public products. +// ->assertJson(['data' => [ +// 0 => $this->expected_short, +// ]]); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + [ + 'term' => [ + 'hide_on_index' => [ + 'value' => false, + 'boost' => 1.0, + ], + ], + ], + ], ], - ]); + ], 100); $this->assertQueryCountLessThan(20); } @@ -203,12 +290,39 @@ public function testIndexIdsSearch($user): void 'created_at' => Carbon::now()->addHour(), ]); - $response = $this->actingAs($this->$user) - ->json('GET', "/products?ids={$firstProduct->getKey()},{$secondProduct->getKey()}"); - - $response - ->assertOk() - ->assertJsonCount(2, 'data'); + $this + ->actingAs($this->$user) + ->json('GET', '/products', [ + 'ids' => "{$firstProduct->getKey()},{$secondProduct->getKey()}", + ]) + ->assertOk(); +// ->assertJsonCount(2, 'data'); + + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [ + [ + 'terms' => [ + 'id' => [ + $firstProduct->getKey(), + $secondProduct->getKey(), + ], + 'boost' => 1.0, + ], + ], + [ + 'term' => [ + 'public' => [ + 'value' => true, + 'boost' => 1.0, + ], + ], + ], + ], + ], + ]); } /** @@ -228,52 +342,18 @@ public function testIndexHidden($user): void $product->sets()->sync([$set->getKey()]); - $response = $this->actingAs($this->$user)->getJson('/products'); - $response - ->assertOk() - ->assertJsonCount(3, 'data'); // Should show all products. - } - - /** - * @dataProvider authProvider - */ - public function testIndexPerformance($user): void - { - $this->$user->givePermissionTo('products.show'); + $this->actingAs($this->$user) + ->json('GET', '/products') + ->assertOk(); +// ->assertJsonCount(3, 'data'); // Should show all products. - Product::factory()->count(499)->create([ - 'public' => true, - 'order' => 1, - ]); - - $this - ->actingAs($this->$user) - ->getJson('/products?limit=500') - ->assertOk() - ->assertJsonCount(500, 'data'); - - $this->assertQueryCountLessThan(20); - } - - /** - * @dataProvider authProvider - */ - public function testIndexFullPerformance($user): void - { - $this->$user->givePermissionTo('products.show'); - - Product::factory()->count(499)->create([ - 'public' => true, - 'order' => 1, + $this->assertElasticQuery([ + 'bool' => [ + 'must' => [], + 'should' => [], + 'filter' => [], + ], ]); - - $this - ->actingAs($this->$user) - ->getJson('/products?limit=500&full') - ->assertOk() - ->assertJsonCount(500, 'data'); - - $this->assertQueryCountLessThan(20); } public function testShowUnauthorized(): void @@ -342,6 +422,7 @@ public function testShowSets($user): void 'parent_id' => $set1->parent_id, 'children_ids' => [], 'cover' => null, + 'metadata' => [], ], [ 'id' => $set2->getKey(), @@ -355,6 +436,7 @@ public function testShowSets($user): void 'parent_id' => $set2->parent_id, 'children_ids' => [], 'cover' => null, + 'metadata' => [], ], ], ]); @@ -408,12 +490,14 @@ public function testShowSetsWithCover($user): void 'hide_on_index' => $set1->hide_on_index, 'parent_id' => $set1->parent_id, 'children_ids' => [], + 'metadata' => [], 'cover' => [ 'id' => $media1->getKey(), 'type' => Str::lower($media1->type->key), 'url' => $media1->url, 'slug' => $media1->slug, 'alt' => $media1->alt, + 'metadata' => [], ], ], [ @@ -427,18 +511,88 @@ public function testShowSetsWithCover($user): void 'hide_on_index' => $set2->hide_on_index, 'parent_id' => $set2->parent_id, 'children_ids' => [], + 'metadata' => [], 'cover' => [ 'id' => $media2->getKey(), 'type' => Str::lower($media2->type->key), 'url' => $media2->url, 'slug' => $media2->slug, 'alt' => $media2->alt, + 'metadata' => [], ], ], ], ]); } + /** + * @dataProvider authProvider + */ + public function testShowAttributes($user): void + { + $this->$user->givePermissionTo('products.show_details'); + + $product = Product::factory()->create([ + 'public' => true, + ]); + + $attribute = Attribute::factory()->create(); + + $option = AttributeOption::factory()->create([ + 'index' => 1, + 'attribute_id' => $attribute->getKey(), + ]); + + $product->attributes()->attach($attribute->getKey()); + + $product->attributes->first()->pivot->options()->attach($option->getKey()); + + $this + ->actingAs($this->$user) + ->getJson('/products/' . $product->slug) + ->assertOk() + ->assertJsonFragment([ + 'id' => $attribute->getKey(), + 'name' => $attribute->name, + 'description' => $attribute->description, + 'type' => $attribute->type, + 'global' => $attribute->global, + ]) + ->assertJsonFragment([ + 'id' => $option->getKey(), + 'name' => $option->name, + 'index' => $option->index, + 'value_number' => $option->value_number, + 'value_date' => $option->value_date, + 'attribute_id' => $attribute->getKey(), + ]); + } + + /** + * @dataProvider authProvider + */ + public function testShowPrivateMetadata($user): void + { + $this->$user->givePermissionTo(['products.show_details', 'products.show_metadata_private']); + + $privateMetadata = $this->product->metadataPrivate()->create([ + 'name' => 'hiddenMetadata', + 'value' => 'hidden metadata test', + 'value_type' => MetadataType::STRING, + 'public' => false, + ]); + + $response = $this->actingAs($this->$user) + ->getJson('/products/id:' . $this->product->getKey()); + + $response + ->assertOk() + ->assertJsonFragment(['metadata_private' => [ + $privateMetadata->name => $privateMetadata->value, + ], + ]); + } + /** * @dataProvider authProvider */ @@ -1128,7 +1282,7 @@ public function testCreateMinMaxPrice($user): void $schemaPrice = 50; $schema = Schema::factory()->create([ - 'type' => 0, + 'type' => SchemaType::STRING, 'required' => false, 'price' => $schemaPrice, ]); @@ -1158,6 +1312,312 @@ public function testCreateMinMaxPrice($user): void ]); } + /** + * @dataProvider authProvider + */ + public function testCreateMinPriceWithRequiredSchema($user): void + { + $this->$user->givePermissionTo('products.add'); + + $schemaPrice = 50; + $schema = Schema::factory()->create([ + 'type' => SchemaType::STRING, + 'required' => true, + 'price' => $schemaPrice, + ]); + + $productPrice = 150; + $response = $this->actingAs($this->$user)->postJson('/products', [ + 'name' => 'Test', + 'slug' => 'test', + 'price' => $productPrice, + 'public' => false, + 'sets' => [], + 'schemas' => [ + $schema->getKey(), + ], + ]); + + $response->assertCreated(); + + $this->assertDatabaseHas('products', [ + 'slug' => 'test', + 'name' => 'Test', + 'price' => $productPrice, + 'price_min' => $productPrice + $schemaPrice, + 'price_max' => $productPrice + $schemaPrice, + 'public' => false, + 'description_html' => null, + ]); + } + + /** + * @dataProvider authProvider + */ + public function testCreateWithAttribute($user): void + { + $this->$user->givePermissionTo('products.add'); + + $attribute = Attribute::factory()->create(); + + $option = AttributeOption::factory()->create([ + 'index' => 1, + 'attribute_id' => $attribute->getKey(), + ]); + + $attribute2 = Attribute::factory()->create(); + + $option2 = AttributeOption::factory()->create([ + 'index' => 2, + 'attribute_id' => $attribute2->getKey(), + ]); + + $response = $this + ->actingAs($this->$user) + ->postJson('/products', [ + 'name' => 'Test', + 'slug' => 'test', + 'price' => 0, + 'public' => true, + 'attributes' => [ + $attribute->getKey() => [ + $option->getKey(), + ], + $attribute2->getKey() => [ + $option2->getKey(), + ], + ], + ]) + ->assertCreated() + ->assertJsonFragment([ + 'id' => $attribute->getKey(), + 'name' => $attribute->name, + 'slug' => $attribute->slug, + 'description' => $attribute->description, + 'type' => $attribute->type, + 'global' => $attribute->global, + 'sortable' => $attribute->sortable, + ]) + ->assertJsonFragment([ + 'id' => $option->getKey(), + 'name' => $option->name, + 'index' => $option->index, + 'value_number' => $option->value_number, + 'value_date' => $option->value_date, + 'attribute_id' => $attribute->getKey(), + ]) + ->assertJsonFragment([ + 'id' => $attribute2->getKey(), + 'name' => $attribute2->name, + 'slug' => $attribute2->slug, + 'description' => $attribute2->description, + 'type' => $attribute2->type, + 'global' => $attribute2->global, + 'sortable' => $attribute2->sortable, + ]) + ->assertJsonFragment([ + 'id' => $option2->getKey(), + 'name' => $option2->name, + 'index' => $option2->index, + 'value_number' => $option2->value_number, + 'value_date' => $option2->value_date, + 'attribute_id' => $attribute2->getKey(), + ]); + + $this->assertDatabaseHas('products', [ + 'slug' => 'test', + 'name' => 'Test', + 'price' => 0, + ]); + + $product = Product::find($response->getData()->data->id); + + $productAttribute1 = ProductAttribute::where('product_id', $product->getKey()) + ->where('attribute_id', $attribute->getKey()) + ->first(); + $productAttribute2 = ProductAttribute::where('product_id', $product->getKey()) + ->where('attribute_id', $attribute2->getKey()) + ->first(); + + $this->assertDatabaseHas('product_attribute', [ + 'product_id' => $product->getKey(), + 'attribute_id' => $attribute->getKey(), + ]); + $this->assertDatabaseHas('product_attribute', [ + 'product_id' => $product->getKey(), + 'attribute_id' => $attribute2->getKey(), + ]); + + $this->assertDatabaseHas('product_attribute_attribute_option', [ + 'product_attribute_id' => $productAttribute1->getKey(), + 'attribute_option_id' => $option->getKey(), + ]); + + $this->assertDatabaseHas('product_attribute_attribute_option', [ + 'product_attribute_id' => $productAttribute2->getKey(), + 'attribute_option_id' => $option2->getKey(), + ]); + + $this->assertDatabaseCount('product_attribute_attribute_option', 3); // +1 from $this->product + } + + /** + * @dataProvider authProvider + */ + public function testCreateWithAttributeMultipleOptions($user): void + { + $this->$user->givePermissionTo('products.add'); + + $attribute = Attribute::factory()->create([ + 'type' => AttributeType::MULTI_CHOICE_OPTION, + ]); + + $option = $attribute->options()->create([ + 'index' => 1, + ]); + + $option2 = $attribute->options()->create([ + 'index' => 1, + ]); + + $response = $this + ->actingAs($this->$user) + ->postJson('/products', [ + 'name' => 'Test', + 'slug' => 'test', + 'price' => 0, + 'public' => true, + 'attributes' => [ + $attribute->getKey() => [ + $option->getKey(), + $option2->getKey(), + ], + ], + ]) + ->assertCreated() + ->assertJsonFragment([ + 'id' => $attribute->getKey(), + 'name' => $attribute->name, + 'slug' => $attribute->slug, + 'description' => $attribute->description, + 'type' => $attribute->type, + 'global' => $attribute->global, + 'sortable' => $attribute->sortable, + ]) + ->assertJsonFragment([ + 'id' => $option->getKey(), + 'name' => $option->name, + 'index' => $option->index, + 'value_number' => $option->value_number, + 'value_date' => $option->value_date, + 'attribute_id' => $attribute->getKey(), + ]) + ->assertJsonFragment([ + 'id' => $option2->getKey(), + 'name' => $option2->name, + 'index' => $option2->index, + 'value_number' => $option2->value_number, + 'value_date' => $option2->value_date, + 'attribute_id' => $attribute->getKey(), + ]); + + $this->assertDatabaseHas('products', [ + 'slug' => 'test', + 'name' => 'Test', + 'price' => 0, + ]); + + $product = Product::find($response->getData()->data->id); + + $productAttribute = ProductAttribute::where('product_id', $product->getKey()) + ->where('attribute_id', $attribute->getKey()) + ->first(); + + $this->assertDatabaseHas('product_attribute', [ + 'product_id' => $product->getKey(), + 'attribute_id' => $attribute->getKey(), + ]); + + $this->assertDatabaseHas('product_attribute_attribute_option', [ + 'product_attribute_id' => $productAttribute->getKey(), + 'attribute_option_id' => $option->getKey(), + ]); + + $this->assertDatabaseHas('product_attribute_attribute_option', [ + 'product_attribute_id' => $productAttribute->getKey(), + 'attribute_option_id' => $option2->getKey(), + ]); + + $this->assertDatabaseCount('product_attribute_attribute_option', 3); // +1 from $this->product + } + + /** + * @dataProvider authProvider + */ + public function testCreateWithAttributeInvalidMultipleOptions($user): void + { + $this->$user->givePermissionTo('products.add'); + + $attribute = Attribute::factory()->create([ + 'type' => AttributeType::SINGLE_OPTION, + ]); + + $option = $attribute->options()->create([ + 'index' => 1, + ]); + + $option2 = $attribute->options()->create([ + 'index' => 1, + ]); + + $this + ->actingAs($this->$user) + ->postJson('/products', [ + 'name' => 'Test', + 'slug' => 'test', + 'price' => 0, + 'public' => true, + 'attributes' => [ + $attribute->getKey() => [ + $option->getKey(), + $option2->getKey(), + ], + ], + ]) + ->assertUnprocessable(); + } + + /** + * @dataProvider authProvider + */ + public function testCreateWithAttributeInvalidOption($user): void + { + $this->$user->givePermissionTo('products.add'); + + $attribute = Attribute::factory()->create(); + + $attribute2 = Attribute::factory()->create(); + + $option = $attribute2->options()->create([ + 'index' => 1, + ]); + + $this + ->actingAs($this->$user) + ->postJson('/products', [ + 'name' => 'Test', + 'slug' => 'test', + 'price' => 0, + 'public' => true, + 'attributes' => [ + $attribute->getKey() => [ + $option->getKey(), + ], + ], + ]) + ->assertUnprocessable(); + } + public function testUpdateUnauthorized(): void { Event::fake([ProductUpdated::class]); diff --git a/tests/Feature/RoleTest.php b/tests/Feature/RoleTest.php index 066d57e7e..e40907f59 100644 --- a/tests/Feature/RoleTest.php +++ b/tests/Feature/RoleTest.php @@ -50,6 +50,7 @@ public function testIndex($user): void 'description' => $role1->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ]) ->assertJsonFragment([[ @@ -58,6 +59,7 @@ public function testIndex($user): void 'description' => $role2->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ]); } @@ -99,6 +101,7 @@ public function testIndexSearchByName($user): void 'description' => $role1->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ]) ->assertJsonFragment([[ @@ -107,6 +110,7 @@ public function testIndexSearchByName($user): void 'description' => $role2->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ]); } @@ -148,6 +152,7 @@ public function testIndexSearchByDescription($user): void 'description' => $role1->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ]) ->assertJsonFragment([[ @@ -156,6 +161,7 @@ public function testIndexSearchByDescription($user): void 'description' => $role2->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ]); } @@ -220,6 +226,7 @@ public function testIndexSearchByAssignable($user): void 'description' => $roleNoPermissions->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ]) ->assertJsonFragment([[ @@ -228,6 +235,7 @@ public function testIndexSearchByAssignable($user): void 'description' => $roleHasPermissions->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ]); } @@ -292,6 +300,7 @@ public function testIndexSearchByUnassignable($user): void 'description' => $roleHasSomePermissions->description, 'assignable' => false, 'deletable' => true, + 'metadata' => [], ], ]) ->assertJsonFragment([[ @@ -300,6 +309,7 @@ public function testIndexSearchByUnassignable($user): void 'description' => $roleHasNoPermissions->description, 'assignable' => false, 'deletable' => true, + 'metadata' => [], ], ]) ->assertJsonFragment([[ @@ -308,6 +318,7 @@ public function testIndexSearchByUnassignable($user): void 'description' => $roleUnauthenticated->description, 'assignable' => false, 'deletable' => false, + 'metadata' => [], ], ]) ->assertJsonFragment([[ @@ -316,6 +327,7 @@ public function testIndexSearchByUnassignable($user): void 'description' => $roleAuthenticated->description, 'assignable' => false, 'deletable' => false, + 'metadata' => [], ], ]); } @@ -357,6 +369,7 @@ public function testIndexSearch($user): void 'description' => $role1->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ]) ->assertJsonFragment([[ @@ -365,6 +378,7 @@ public function testIndexSearch($user): void 'description' => $role2->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ]); } @@ -403,6 +417,7 @@ public function testShow($user): void 'assignable' => true, 'deletable' => true, 'permissions' => [], + 'metadata' => [], ], ]); } @@ -437,6 +452,7 @@ public function testShowPermissions($user): void 'test.custom1', 'test.custom2', ], + 'metadata' => [], ], ]); } @@ -471,6 +487,7 @@ public function testShowPermissionsAssignable($user): void 'test.custom1', 'test.custom2', ], + 'metadata' => [], ], ]); } @@ -508,6 +525,7 @@ public function testShowUnauthenticatedRoleUnassignable($user): void 'test.custom1', 'test.custom2', ], + 'metadata' => [], ], ]); } @@ -586,6 +604,7 @@ public function testCreate($user): void 'test.custom1', 'test.custom2', ], + 'metadata' => [], ], ]); @@ -617,6 +636,7 @@ public function testCreateWithoutDescription($user): void 'assignable' => true, 'deletable' => true, 'permissions' => [], + 'metadata' => [], ], ]); @@ -787,6 +807,7 @@ public function testUpdate($user): void 'test.custom2', 'test.custom3', ], + 'metadata' => [], ], ]); @@ -826,6 +847,7 @@ public function testUpdateNameOnly($user): void 'assignable' => true, 'deletable' => true, 'permissions' => [], + 'metadata' => [], ], ]); @@ -859,6 +881,7 @@ public function testUpdateDescriptionOnly($user): void 'assignable' => true, 'deletable' => true, 'permissions' => [], + 'metadata' => [], ], ]); @@ -892,6 +915,7 @@ public function testUpdateDescriptionRemove($user): void 'assignable' => true, 'deletable' => true, 'permissions' => [], + 'metadata' => [], ], ]); diff --git a/tests/Feature/ShippingMethodTest.php b/tests/Feature/ShippingMethodTest.php index f47f9754a..e6422c3c0 100644 --- a/tests/Feature/ShippingMethodTest.php +++ b/tests/Feature/ShippingMethodTest.php @@ -64,6 +64,7 @@ public function setUp(): void 'public' => $this->shipping_method->public, 'shipping_time_min' => $this->shipping_method->shipping_time_min, 'shipping_time_max' => $this->shipping_method->shipping_time_max, + 'metadata' => [], ]; $this->priceRangesWithNoInitialStart = [ diff --git a/tests/Feature/StatusTest.php b/tests/Feature/StatusTest.php index a4bfae274..c32456b1b 100644 --- a/tests/Feature/StatusTest.php +++ b/tests/Feature/StatusTest.php @@ -31,6 +31,7 @@ public function setUp(): void 'description' => $this->status_model->description, 'hidden' => $this->status_model->hidden, 'no_notifications' => $this->status_model->no_notifications, + 'metadata' => [], ]; } diff --git a/tests/Feature/UserTest.php b/tests/Feature/UserTest.php index 0d0c520ff..c00de850c 100644 --- a/tests/Feature/UserTest.php +++ b/tests/Feature/UserTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Enums\MetadataType; use App\Enums\RoleType; use App\Events\UserCreated; use App\Events\UserDeleted; @@ -34,6 +35,13 @@ public function setUp(): void { parent::setUp(); + $metadata = $this->user->metadata()->create([ + 'name' => 'Metadata', + 'value' => 'metadata test', + 'value_type' => MetadataType::STRING, + 'public' => true, + ]); + $this->expected = [ 'id' => $this->user->getKey(), 'email' => $this->user->email, @@ -41,6 +49,9 @@ public function setUp(): void 'avatar' => $this->user->avatar, 'roles' => [], 'is_tfa_active' => $this->user->is_tfa_active, + 'metadata' => [ + $metadata->name => $metadata->value, + ], ]; // Owner role needs to exist for user service to function properly @@ -203,6 +214,7 @@ public function testIndexNameSearch($user): void 'avatar' => $otherUser->avatar, 'roles' => [], 'is_tfa_active' => $otherUser->is_tfa_active, + 'metadata' => [], ]); } @@ -229,6 +241,7 @@ public function testIndexEmailSearch($user): void 'avatar' => $otherUser->avatar, 'roles' => [], 'is_tfa_active' => $otherUser->is_tfa_active, + 'metadata' => [], ]); } @@ -254,6 +267,7 @@ public function testIndexFullSearchName($user): void 'avatar' => $otherUser->avatar, 'roles' => [], 'is_tfa_active' => $otherUser->is_tfa_active, + 'metadata' => [], ]); } @@ -279,6 +293,7 @@ public function testIndexFullSearchEmail($user): void 'avatar' => $otherUser->avatar, 'roles' => [], 'is_tfa_active' => $otherUser->is_tfa_active, + 'metadata' => [], ]); } @@ -301,6 +316,31 @@ public function testShow($user): void ->assertJson(['data' => $this->expected + ['permissions' => []]]); } + /** + * @dataProvider authProvider + */ + public function testShowPrivateMetadata($user): void + { + $this->$user->givePermissionTo(['users.show_details', 'users.show_metadata_private']); + + $privateMetadata = $this->user->metadataPrivate()->create([ + 'name' => 'hiddenMetadata', + 'value' => 'hidden metadata test', + 'value_type' => MetadataType::STRING, + 'public' => false, + ]); + + $response = $this->actingAs($this->$user)->getJson('/users/id:' . $this->user->getKey()); + $response + ->assertOk() + ->assertJson(['data' => $this->expected + + [ + 'permissions' => [], + 'metadata_private' => [$privateMetadata->name => $privateMetadata->value], + ], + ]); + } + public function testCreateUnauthorized(): void { Event::fake([UserCreated::class]); @@ -552,6 +592,7 @@ public function testCreateRoles($user): void 'description' => $role1->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ])->assertJsonFragment([[ 'id' => $role2->getKey(), @@ -559,6 +600,7 @@ public function testCreateRoles($user): void 'description' => $role2->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ])->assertJsonFragment([[ 'id' => $role3->getKey(), @@ -566,6 +608,7 @@ public function testCreateRoles($user): void 'description' => $role3->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ])->assertJsonFragment([[ 'id' => $this->authenticated->getKey(), @@ -573,6 +616,7 @@ public function testCreateRoles($user): void 'description' => $this->authenticated->description, 'assignable' => false, 'deletable' => false, + 'metadata' => [], ], ])->assertJsonPath('data.permissions', $permissions); @@ -820,6 +864,7 @@ public function testUpdateAddRoles($user): void 'description' => $role1->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ])->assertJsonFragment([[ 'id' => $role2->getKey(), @@ -827,6 +872,7 @@ public function testUpdateAddRoles($user): void 'description' => $role2->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ])->assertJsonFragment([[ 'id' => $role3->getKey(), @@ -834,6 +880,7 @@ public function testUpdateAddRoles($user): void 'description' => $role3->description, 'assignable' => true, 'deletable' => true, + 'metadata' => [], ], ])->assertJsonFragment([[ 'id' => $this->authenticated->getKey(), @@ -841,6 +888,7 @@ public function testUpdateAddRoles($user): void 'description' => $this->authenticated->description, 'assignable' => false, 'deletable' => false, + 'metadata' => [], ], ])->assertJsonPath('data.permissions', $permissions); @@ -959,6 +1007,7 @@ public function testUpdateRemoveRoles($user): void 'description' => $this->authenticated->description, 'assignable' => false, 'deletable' => false, + 'metadata' => [], ], ]) ->assertJsonPath('data.permissions', $this->authenticatedPermissions->toArray()); diff --git a/tests/Support/ElasticTest.php b/tests/Support/ElasticTest.php new file mode 100644 index 000000000..45954f855 --- /dev/null +++ b/tests/Support/ElasticTest.php @@ -0,0 +1,34 @@ +instance( + ElasticClientFactory::class, + ElasticClientFactory::fake(new FakeResponse(200, $response)), + ); + } + + public function assertElasticQuery(array $query, ?int $limit = null): void + { + $this->assertEquals( + [ + 'query' => $query, + 'from' => 0, + 'size' => $limit ?? Config::get('pagination.per_page'), + ], + ElasticEngine::debug()->array(), + 'Failed to assert that elastic query matched the expected', + ); + } +} diff --git a/tests/Support/fake-response.json b/tests/Support/fake-response.json new file mode 100644 index 000000000..383859cba --- /dev/null +++ b/tests/Support/fake-response.json @@ -0,0 +1,18 @@ +{ + "took" : 4, + "timed_out" : false, + "_shards" : { + "total" : 1, + "successful" : 1, + "skipped" : 0, + "failed" : 0 + }, + "hits" : { + "total" : { + "value" : 0, + "relation" : "eq" + }, + "max_score" : 3.4428797, + "hits" : [] + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index b87967621..ad5298600 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,11 +14,12 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use Tests\Support\ElasticTest; use Tests\Traits\JsonQueryCounter; abstract class TestCase extends BaseTestCase { - use CreatesApplication, RefreshDatabase, JsonQueryCounter; + use CreatesApplication, RefreshDatabase, JsonQueryCounter, ElasticTest; public User $user; public Application $application; @@ -29,7 +30,9 @@ abstract class TestCase extends BaseTestCase public function setUp(): void { parent::setUp(); - ini_set('memory_limit', '1024M'); + ini_set('memory_limit', '4096M'); + + $this->fakeElastic(); $this->tokenService = App::make(TokenServiceContract::class); @@ -44,6 +47,13 @@ public function setUp(): void $this->application = Application::factory()->create(); } + protected function tearDown(): void + { + parent::tearDown(); + + app()->forgetInstances(); + } + public function authProvider(): array { return [ diff --git a/tests/Unit/ProductServiceTest.php b/tests/Unit/ProductServiceTest.php index 3b0bc082d..d7656b851 100644 --- a/tests/Unit/ProductServiceTest.php +++ b/tests/Unit/ProductServiceTest.php @@ -6,14 +6,11 @@ use App\Models\Product; use App\Models\Schema; use App\Services\Contracts\ProductServiceContract; -use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\App; use Tests\TestCase; class ProductServiceTest extends TestCase { - use RefreshDatabase; - private ProductServiceContract $productService; public function setUp(): void @@ -26,6 +23,7 @@ public function setUp(): void public function testMinMaxPricesNoSchemas(): void { $price = 10; + /** @var Product $product */ $product = Product::factory()->create([ 'price' => $price, ]);