feat(search): implement catalog item search functionality with request validation and resource formatting
This commit is contained in:
@@ -8,16 +8,19 @@ use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Models\FeaturedItem;
|
||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
||||
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
||||
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
||||
use App\Domains\Catalog\Resources\CatalogItemResource;
|
||||
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class CatalogController extends Controller
|
||||
@@ -39,6 +42,21 @@ class CatalogController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function search(
|
||||
SearchCatalogItemsRequest $request,
|
||||
Tenant $tenant,
|
||||
CatalogService $catalogService,
|
||||
): AnonymousResourceCollection {
|
||||
return CatalogSearchItemResource::collection(
|
||||
$catalogService->search(
|
||||
$tenant,
|
||||
$request->validated('q'),
|
||||
$tenant->search_items_per_page,
|
||||
(int) $request->validated('page', 1),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function featuredGroupItems(
|
||||
FeaturedGroupPageRequest $request,
|
||||
Tenant $tenant,
|
||||
|
||||
29
app/Domains/Catalog/Requests/SearchCatalogItemsRequest.php
Normal file
29
app/Domains/Catalog/Requests/SearchCatalogItemsRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SearchCatalogItemsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'q' => ['required', 'string', 'min:2', 'max:100'],
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('q') && is_string($this->input('q'))) {
|
||||
$this->merge(['q' => trim($this->string('q')->toString())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/Domains/Catalog/Resources/CatalogSearchItemResource.php
Normal file
45
app/Domains/Catalog/Resources/CatalogSearchItemResource.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin CatalogItem */
|
||||
class CatalogSearchItemResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$attachment = $this->attachments->first()
|
||||
?? $this->variants
|
||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||
->first();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'type' => $this->type->value,
|
||||
'nombre' => $this->nombre,
|
||||
'descripcion' => $this->descripcion,
|
||||
'precio' => $this->precio,
|
||||
'image' => $attachment?->getTemporaryUrl(1440),
|
||||
'stock_tecnico' => $this->availableStock(),
|
||||
'variants' => $this->variants
|
||||
->map(fn (Variant $variant): array => [
|
||||
'id' => $variant->id,
|
||||
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory?->availableStock(),
|
||||
'values' => $variant->definitions
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||
])
|
||||
->filter(fn ($value, $key): bool => $key !== null),
|
||||
])
|
||||
->values(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -158,6 +161,55 @@ class CatalogService
|
||||
return $catalogItem;
|
||||
}
|
||||
|
||||
/** @return LengthAwarePaginator<CatalogItem> */
|
||||
public function search(
|
||||
Tenant $tenant,
|
||||
string $term,
|
||||
int $perPage,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
$normalizedTerm = mb_strtolower($term);
|
||||
$containsPattern = "%{$normalizedTerm}%";
|
||||
$startsWithPattern = "{$normalizedTerm}%";
|
||||
|
||||
$paginator = CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where(function (Builder $query) use ($containsPattern): void {
|
||||
$query
|
||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||
->orWhereRaw('LOWER(descripcion) LIKE ?', [$containsPattern])
|
||||
->orWhereHas(
|
||||
'brand',
|
||||
fn (Builder $brandQuery) => $brandQuery
|
||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||
)
|
||||
->orWhereHas(
|
||||
'category',
|
||||
fn (Builder $categoryQuery) => $categoryQuery
|
||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||
);
|
||||
})
|
||||
->with([
|
||||
'attachments',
|
||||
'inventory',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
])
|
||||
->orderByRaw(
|
||||
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
|
||||
[$normalizedTerm, $startsWithPattern],
|
||||
)
|
||||
->orderBy('nombre')
|
||||
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||
|
||||
return $paginator->withPath(route('catalog-items.index', [
|
||||
'tenant' => $tenant->codigo,
|
||||
]));
|
||||
}
|
||||
|
||||
public function delete(CatalogItem $catalogItem): void
|
||||
{
|
||||
DB::transaction(function () use ($catalogItem): void {
|
||||
|
||||
@@ -7,6 +7,8 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
Route::get('catalog', [CatalogController::class, 'index']);
|
||||
Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
|
||||
->name('catalog.featured-groups.items.index');
|
||||
Route::get('catalog-items', [CatalogController::class, 'search'])
|
||||
->name('catalog-items.index');
|
||||
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Domains\Tenant\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Models\TenantMenu;
|
||||
@@ -27,11 +29,20 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'footer_logo_id',
|
||||
'hero_config',
|
||||
'event_config',
|
||||
'search_product_layout',
|
||||
'search_group_layout',
|
||||
'search_items_per_page',
|
||||
])]
|
||||
class Tenant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $attributes = [
|
||||
'search_product_layout' => ProductLayout::ColumnWithImage->value,
|
||||
'search_group_layout' => GroupLayout::Paginated->value,
|
||||
'search_items_per_page' => 12,
|
||||
];
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'codigo';
|
||||
@@ -47,6 +58,9 @@ class Tenant extends Model
|
||||
return [
|
||||
'hero_config' => 'array',
|
||||
'event_config' => 'array',
|
||||
'search_product_layout' => ProductLayout::class,
|
||||
'search_group_layout' => GroupLayout::class,
|
||||
'search_items_per_page' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Closure;
|
||||
@@ -82,6 +84,9 @@ class StoreTenantRequest extends FormRequest
|
||||
'event_config.location' => ['nullable', 'string'],
|
||||
'event_config.dates' => ['nullable', 'array'],
|
||||
'event_config.dates.*' => ['required', 'string'],
|
||||
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
|
||||
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
|
||||
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
@@ -93,6 +95,9 @@ class UpdateTenantRequest extends FormRequest
|
||||
'event_config.location' => ['nullable', 'string'],
|
||||
'event_config.dates' => ['nullable', 'array'],
|
||||
'event_config.dates.*' => ['required', 'string'],
|
||||
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
|
||||
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
|
||||
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ class TenantResource extends JsonResource
|
||||
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
|
||||
'hero_config' => $heroConfig,
|
||||
'event_config' => $this->event_config,
|
||||
'search_product_layout' => $this->search_product_layout->value,
|
||||
'search_group_layout' => $this->search_group_layout->value,
|
||||
'search_items_per_page' => $this->search_items_per_page,
|
||||
'main_carousel_images' => $this->whenLoaded(
|
||||
'mainCarouselImages',
|
||||
fn () => $this->mainCarouselImages
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table): void {
|
||||
$table->enum('search_product_layout', ProductLayout::values())
|
||||
->default(ProductLayout::ColumnWithImage->value);
|
||||
$table->enum('search_group_layout', GroupLayout::values())
|
||||
->default(GroupLayout::Paginated->value);
|
||||
$table->unsignedTinyInteger('search_items_per_page')->default(12);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table): void {
|
||||
$table->dropColumn([
|
||||
'search_product_layout',
|
||||
'search_group_layout',
|
||||
'search_items_per_page',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
132
tests/Feature/Catalog/CatalogSearchTest.php
Normal file
132
tests/Feature/Catalog/CatalogSearchTest.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Catalog;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CatalogSearchTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_every_tenant_gets_default_search_layouts(): void
|
||||
{
|
||||
$tenant = $this->createTenant('search-default');
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'codigo' => $tenant->codigo,
|
||||
'search_product_layout' => ProductLayout::ColumnWithImage->value,
|
||||
'search_group_layout' => GroupLayout::Paginated->value,
|
||||
'search_items_per_page' => 12,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_search_layouts_can_be_updated_on_the_tenant_and_are_returned_by_bootstrap(): void
|
||||
{
|
||||
$tenant = $this->createTenant('search-config');
|
||||
|
||||
$this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||
'search_product_layout' => ProductLayout::Row->value,
|
||||
'search_group_layout' => GroupLayout::SimpleVertical->value,
|
||||
'search_items_per_page' => 24,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.search_product_layout', ProductLayout::Row->value)
|
||||
->assertJsonPath('data.search_group_layout', GroupLayout::SimpleVertical->value)
|
||||
->assertJsonPath('data.search_items_per_page', 24);
|
||||
|
||||
$this->getJson("/api/tenants/bootstrap/{$tenant->dominio}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.search_product_layout', ProductLayout::Row->value)
|
||||
->assertJsonPath('data.search_group_layout', GroupLayout::SimpleVertical->value)
|
||||
->assertJsonPath('data.search_items_per_page', 24);
|
||||
}
|
||||
|
||||
public function test_search_is_tenant_scoped_relevant_and_uses_configured_page_size(): void
|
||||
{
|
||||
$tenant = $this->createTenant('search-items');
|
||||
$otherTenant = $this->createTenant('other-search-items');
|
||||
$tenant->update(['search_items_per_page' => 4]);
|
||||
|
||||
foreach (range(1, 5) as $number) {
|
||||
$this->createCatalogItem($tenant, "Running {$number}");
|
||||
}
|
||||
$exactMatch = $this->createCatalogItem($tenant, 'Running');
|
||||
$this->createCatalogItem($tenant, 'Unrelated');
|
||||
$this->createCatalogItem($otherTenant, 'Running foreign');
|
||||
|
||||
$response = $this->getJson(
|
||||
"/api/tenants/{$tenant->codigo}/catalog-items?q=running"
|
||||
);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('meta.current_page', 1)
|
||||
->assertJsonPath('meta.per_page', 4)
|
||||
->assertJsonPath('meta.total', 6)
|
||||
->assertJsonCount(4, 'data')
|
||||
->assertJsonPath('data.0.id', $exactMatch->id)
|
||||
->assertJsonMissing(['nombre' => 'Running foreign'])
|
||||
->assertJsonMissing(['nombre' => 'Unrelated']);
|
||||
}
|
||||
|
||||
public function test_search_validates_the_query(): void
|
||||
{
|
||||
$tenant = $this->createTenant('search-validation');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items?q=a")
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('q');
|
||||
}
|
||||
|
||||
private function createCatalogItem(Tenant $tenant, string $name): CatalogItem
|
||||
{
|
||||
$inventory = Inventory::query()->create(['real_stock' => 10]);
|
||||
|
||||
return CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'inventory_id' => $inventory->id,
|
||||
'slug' => str($name)->slug()->toString(),
|
||||
'nombre' => $name,
|
||||
'descripcion' => "{$name} description",
|
||||
'precio' => 100,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createTenant(string $code): Tenant
|
||||
{
|
||||
$headerLogo = $this->createAttachment("{$code}-header");
|
||||
$footerLogo = $this->createAttachment("{$code}-footer");
|
||||
|
||||
return Tenant::query()->create([
|
||||
'codigo' => $code,
|
||||
'nombre' => ucfirst($code),
|
||||
'dominio' => "{$code}.local",
|
||||
'primary_color' => '#000000',
|
||||
'secondary_color' => '#000000',
|
||||
'danger_color' => '#000000',
|
||||
'success_color' => '#000000',
|
||||
'header_bg_color' => '#000000',
|
||||
'footer_bg_color' => '#000000',
|
||||
'header_logo_id' => $headerLogo->id,
|
||||
'footer_logo_id' => $footerLogo->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createAttachment(string $name): Attachment
|
||||
{
|
||||
return Attachment::query()->create([
|
||||
'path' => "test/{$name}.png",
|
||||
'filename' => "{$name}.png",
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user