diff --git a/app/Domains/Catalog/Controllers/AdminApp/OnTicketFeaturedGroupController.php b/app/Domains/Catalog/Controllers/AdminApp/OnTicketFeaturedGroupController.php new file mode 100644 index 0000000..7017d66 --- /dev/null +++ b/app/Domains/Catalog/Controllers/AdminApp/OnTicketFeaturedGroupController.php @@ -0,0 +1,65 @@ +featuredGroupService->forTenant($this->onTicketTenant($request)) + ); + } + + public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse + { + $featuredGroup = $this->featuredGroupService->create( + $this->onTicketTenant($request), + $request->validated(), + ); + + return OnTicketFeaturedGroupResource::make($featuredGroup) + ->response() + ->setStatusCode(201); + } + + public function update( + UpsertOnTicketFeaturedGroupRequest $request, + FeaturedGroup $featuredGroup, + ): OnTicketFeaturedGroupResource { + $tenant = $this->onTicketTenant($request); + + abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404); + + return OnTicketFeaturedGroupResource::make( + $this->featuredGroupService->update( + $tenant, + $featuredGroup, + $request->validated(), + ) + ); + } + + private function onTicketTenant(Request $request): Tenant + { + $tenant = $request->user()->tenant()->firstOrFail(); + + abort_unless($tenant->website_type_code === 'onticket', 404); + + return $tenant; + } +} diff --git a/app/Domains/Catalog/Requests/AdminApp/UpsertOnTicketFeaturedGroupRequest.php b/app/Domains/Catalog/Requests/AdminApp/UpsertOnTicketFeaturedGroupRequest.php new file mode 100644 index 0000000..9d7423d --- /dev/null +++ b/app/Domains/Catalog/Requests/AdminApp/UpsertOnTicketFeaturedGroupRequest.php @@ -0,0 +1,22 @@ + */ + public function rules(): array + { + return [ + 'category_name' => ['required', 'string', 'max:255'], + 'is_featured' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Domains/Catalog/Resources/AdminApp/OnTicketFeaturedGroupResource.php b/app/Domains/Catalog/Resources/AdminApp/OnTicketFeaturedGroupResource.php new file mode 100644 index 0000000..752f4b9 --- /dev/null +++ b/app/Domains/Catalog/Resources/AdminApp/OnTicketFeaturedGroupResource.php @@ -0,0 +1,28 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'category_id' => $this->category_id, + 'category_name' => $this->category->nombre, + 'group_name' => $this->group_name, + 'is_featured' => $this->product_layout === ProductLayout::Row, + 'type' => $this->source_type->value, + 'product_layout' => $this->product_layout->value, + 'group_layout' => $this->group_layout->value, + 'order' => $this->group_order, + ]; + } +} diff --git a/app/Domains/Catalog/Services/OnTicketFeaturedGroupService.php b/app/Domains/Catalog/Services/OnTicketFeaturedGroupService.php new file mode 100644 index 0000000..6b2300a --- /dev/null +++ b/app/Domains/Catalog/Services/OnTicketFeaturedGroupService.php @@ -0,0 +1,95 @@ + */ + public function forTenant(Tenant $tenant): Collection + { + return FeaturedGroup::query() + ->where('tenant_code', $tenant->codigo) + ->where('source_type', FeaturedGroupSource::Category) + ->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo)) + ->with('category') + ->orderBy('group_order') + ->orderBy('id') + ->get(); + } + + /** @param array{category_name: string, is_featured: bool} $data */ + public function create(Tenant $tenant, array $data): FeaturedGroup + { + return DB::transaction(function () use ($tenant, $data): FeaturedGroup { + $category = $tenant->categories()->create([ + 'nombre' => $data['category_name'], + ]); + + $featuredGroup = FeaturedGroup::query()->create([ + 'tenant_code' => $tenant->codigo, + 'source_type' => FeaturedGroupSource::Category, + 'category_id' => $category->id, + 'product_layout' => $this->productLayout($data['is_featured']), + 'group_layout' => GroupLayout::Paginated, + 'group_name' => $data['category_name'], + 'group_order' => $this->nextOrder($tenant), + ]); + + return $featuredGroup->setRelation('category', $category); + }); + } + + /** @param array{category_name: string, is_featured: bool} $data */ + public function update( + Tenant $tenant, + FeaturedGroup $featuredGroup, + array $data, + ): FeaturedGroup { + return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup { + $featuredGroup = FeaturedGroup::query() + ->whereKey($featuredGroup->getKey()) + ->where('tenant_code', $tenant->codigo) + ->where('source_type', FeaturedGroupSource::Category) + ->lockForUpdate() + ->firstOrFail(); + + $category = Category::query() + ->whereKey($featuredGroup->category_id) + ->where('tenant_code', $tenant->codigo) + ->lockForUpdate() + ->firstOrFail(); + + $category->update(['nombre' => $data['category_name']]); + $featuredGroup->update([ + 'group_name' => $data['category_name'], + 'product_layout' => $this->productLayout($data['is_featured']), + 'group_layout' => GroupLayout::Paginated, + ]); + + return $featuredGroup->setRelation('category', $category); + }); + } + + private function productLayout(bool $isFeatured): ProductLayout + { + return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart; + } + + private function nextOrder(Tenant $tenant): int + { + $maximumOrder = FeaturedGroup::query() + ->where('tenant_code', $tenant->codigo) + ->max('group_order'); + + return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1; + } +} diff --git a/app/Domains/Catalog/routes/adminapp.php b/app/Domains/Catalog/routes/adminapp.php new file mode 100644 index 0000000..7a63fc2 --- /dev/null +++ b/app/Domains/Catalog/routes/adminapp.php @@ -0,0 +1,15 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index']) + ->name('adminapp.featured-groups.index'); + Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store']) + ->name('adminapp.featured-groups.store'); + Route::put('featured-groups/{featuredGroup}', [OnTicketFeaturedGroupController::class, 'update']) + ->name('adminapp.featured-groups.update'); + }); diff --git a/app/Domains/Catalog/routes/api.php b/app/Domains/Catalog/routes/api.php index b09e8c7..2257693 100644 --- a/app/Domains/Catalog/routes/api.php +++ b/app/Domains/Catalog/routes/api.php @@ -14,3 +14,5 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void { Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']); Route::post('catalog-items', [CatalogController::class, 'store']); }); + +require __DIR__.'/adminapp.php'; diff --git a/tests/Feature/Catalog/OnTicketFeaturedGroupControllerTest.php b/tests/Feature/Catalog/OnTicketFeaturedGroupControllerTest.php new file mode 100644 index 0000000..9c0d73c --- /dev/null +++ b/tests/Feature/Catalog/OnTicketFeaturedGroupControllerTest.php @@ -0,0 +1,262 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + WebsiteType::query()->create([ + 'codigo' => 'shopit', + 'nombre' => 'Shopit', + ]); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/tenant/featured-groups')->assertUnauthorized(); + $this->postJson('/api/v1/adminapp/tenant/featured-groups', $this->payload()) + ->assertUnauthorized(); + $this->putJson('/api/v1/adminapp/tenant/featured-groups/1', $this->payload()) + ->assertUnauthorized(); + } + + public function test_index_returns_only_category_groups_for_the_onticket_tenant(): void + { + $tenant = $this->createTenant('acme'); + $otherTenant = $this->createTenant('other'); + $first = $this->createCategoryGroup($tenant, 'Food', order: 2); + $second = $this->createCategoryGroup($tenant, 'Tickets', order: 1); + $this->createCategoryGroup($otherTenant, 'Other tenant'); + FeaturedGroup::query()->create([ + 'tenant_code' => $tenant->codigo, + 'source_type' => FeaturedGroupSource::All, + 'product_layout' => ProductLayout::Row, + 'group_layout' => GroupLayout::Paginated, + 'group_name' => 'All products', + 'group_order' => 0, + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/featured-groups') + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $second->id) + ->assertJsonPath('data.0.category_name', 'Tickets') + ->assertJsonPath('data.1.id', $first->id) + ->assertJsonMissing(['category_name' => 'Other tenant']) + ->assertJsonMissing(['group_name' => 'All products']); + } + + public function test_store_creates_a_category_and_a_featured_horizontal_group(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $response = $this->postJson('/api/v1/adminapp/tenant/featured-groups', [ + 'category_name' => 'Food', + 'is_featured' => true, + ]) + ->assertCreated() + ->assertJsonPath('data.category_name', 'Food') + ->assertJsonPath('data.group_name', 'Food') + ->assertJsonPath('data.is_featured', true) + ->assertJsonPath('data.type', 'category') + ->assertJsonPath('data.product_layout', 'row') + ->assertJsonPath('data.group_layout', 'paginated'); + + $categoryId = $response->json('data.category_id'); + $this->assertDatabaseHas('categorias', [ + 'id' => $categoryId, + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Food', + ]); + $this->assertDatabaseHas('featured_groups', [ + 'tenant_code' => $tenant->codigo, + 'source_type' => 'category', + 'category_id' => $categoryId, + 'product_layout' => 'row', + 'group_layout' => 'paginated', + 'group_name' => 'Food', + ]); + } + + public function test_store_uses_column_with_cart_when_the_category_is_not_featured(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/featured-groups', [ + 'category_name' => 'Parking', + 'is_featured' => false, + ]) + ->assertCreated() + ->assertJsonPath('data.is_featured', false) + ->assertJsonPath('data.product_layout', 'column_with_cart') + ->assertJsonPath('data.group_layout', 'paginated'); + } + + public function test_update_changes_the_category_and_group_together(): void + { + $tenant = $this->createTenant('acme'); + $group = $this->createCategoryGroup($tenant, 'Old name', ProductLayout::ColumnWithCart); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->putJson("/api/v1/adminapp/tenant/featured-groups/{$group->id}", [ + 'category_name' => 'New name', + 'is_featured' => true, + ]) + ->assertOk() + ->assertJsonPath('data.category_name', 'New name') + ->assertJsonPath('data.group_name', 'New name') + ->assertJsonPath('data.is_featured', true) + ->assertJsonPath('data.product_layout', 'row') + ->assertJsonPath('data.group_layout', 'paginated'); + + $this->assertDatabaseHas('categorias', [ + 'id' => $group->category_id, + 'nombre' => 'New name', + ]); + $this->assertDatabaseHas('featured_groups', [ + 'id' => $group->id, + 'group_name' => 'New name', + 'product_layout' => 'row', + 'group_layout' => 'paginated', + ]); + } + + public function test_update_rejects_groups_from_another_tenant_or_source(): void + { + $tenant = $this->createTenant('acme'); + $otherTenant = $this->createTenant('other'); + $otherGroup = $this->createCategoryGroup($otherTenant, 'Other'); + $allGroup = FeaturedGroup::query()->create([ + 'tenant_code' => $tenant->codigo, + 'source_type' => FeaturedGroupSource::All, + 'product_layout' => ProductLayout::Row, + 'group_layout' => GroupLayout::Paginated, + 'group_name' => 'All products', + 'group_order' => 0, + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->putJson( + "/api/v1/adminapp/tenant/featured-groups/{$otherGroup->id}", + $this->payload(), + )->assertNotFound(); + $this->putJson( + "/api/v1/adminapp/tenant/featured-groups/{$allGroup->id}", + $this->payload(), + )->assertNotFound(); + } + + public function test_name_and_featured_flag_are_required(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/featured-groups', [ + 'category_name' => '', + 'is_featured' => 'yes', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['category_name', 'is_featured']); + + $this->assertDatabaseCount('categorias', 0); + $this->assertDatabaseCount('featured_groups', 0); + } + + public function test_the_controller_is_not_available_for_non_onticket_tenants(): void + { + $tenant = $this->createTenant('store', 'shopit'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/featured-groups')->assertNotFound(); + $this->postJson('/api/v1/adminapp/tenant/featured-groups', $this->payload()) + ->assertNotFound(); + } + + public function test_a_customer_cannot_manage_onticket_featured_groups(): void + { + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => null, + ])); + + $this->getJson('/api/v1/adminapp/tenant/featured-groups')->assertForbidden(); + $this->postJson('/api/v1/adminapp/tenant/featured-groups', $this->payload()) + ->assertForbidden(); + } + + /** @return array{category_name: string, is_featured: bool} */ + private function payload(): array + { + return [ + 'category_name' => 'Food', + 'is_featured' => true, + ]; + } + + private function createTenant(string $code, string $websiteType = 'onticket'): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => $websiteType, + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function createCategoryGroup( + Tenant $tenant, + string $name, + ProductLayout $productLayout = ProductLayout::Row, + int $order = 0, + ): FeaturedGroup { + $category = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => $name, + ]); + + return FeaturedGroup::query()->create([ + 'tenant_code' => $tenant->codigo, + 'source_type' => FeaturedGroupSource::Category, + 'category_id' => $category->id, + 'product_layout' => $productLayout, + 'group_layout' => GroupLayout::Paginated, + 'group_name' => $name, + 'group_order' => $order, + ]); + } +}