diff --git a/src/Dtos/PageDto.php b/src/Dtos/PageDto.php new file mode 100644 index 0000000..0b0b7e3 --- /dev/null +++ b/src/Dtos/PageDto.php @@ -0,0 +1,55 @@ +skip = $skip; + $this->per_page = $per_page; + } + + public function toArray(): array + { + return [ + 'skip' => $this->getSkip(), + 'per_page' => $this->getPerPage() + ]; + } + + public static function instantiateFromRequest(Request $request): self + { + $per_page = config('paginate.default.limit', 15); + + if ($request->get('page')) { + return new self( + $request->get('skip', ($request->get('page') - 1) * $per_page), + $request->get('per_page', $per_page), + ); + } + + return new self( + $request->get('skip', config('paginate.default.limit', 0)), + $request->get('per_page', $per_page), + ); + } + + public function getSkip(): ?int + { + return $this->skip; + } + + public function getPerPage(): ?int + { + return $this->per_page; + } +} diff --git a/src/Dtos/ProductSearchMyCriteriaDto.php b/src/Dtos/ProductSearchMyCriteriaDto.php new file mode 100644 index 0000000..10b1b71 --- /dev/null +++ b/src/Dtos/ProductSearchMyCriteriaDto.php @@ -0,0 +1,44 @@ +push(new HasCriterion('users', fn (Builder $query) => $query->where('user_id', $request->user()->getKey()))); + + if ($request->has('type')) { + $criteria->push(new EqualCriterion('type', $request->get('type'))); + } + + if ($request->has('active')) { + if ($request->boolean('active')) + $criteria->push(new HasCriterion('users', fn (Builder $query) => $query + ->where('user_id', $request->user()->getKey()) + ->where(fn (Builder $query) => $query + ->whereDate('end_date', '>=', Carbon::now()) + ->orWhereNull('end_date')) + ) + ); + else + $criteria->push(new HasCriterion('users', fn (Builder $query) => $query + ->where('user_id', $request->user()->getKey()) + ->where('end_date', '<', Carbon::now()))); + } + + return new self($criteria); + } +} diff --git a/src/Http/Controllers/ProductApiController.php b/src/Http/Controllers/ProductApiController.php index 515d959..66cf348 100644 --- a/src/Http/Controllers/ProductApiController.php +++ b/src/Http/Controllers/ProductApiController.php @@ -3,7 +3,9 @@ namespace EscolaLms\Cart\Http\Controllers; use EscolaLms\Cart\Http\Requests\ProductReadRequest; +use EscolaLms\Cart\Http\Requests\ProductSearchMyRequest; use EscolaLms\Cart\Http\Requests\ProductSearchRequest; +use EscolaLms\Cart\Http\Resources\MyProductResource; use EscolaLms\Cart\Http\Resources\ProductResource; use EscolaLms\Cart\Http\Swagger\ProductSwagger; use EscolaLms\Cart\Services\Contracts\ProductServiceContract; @@ -35,4 +37,11 @@ public function read(ProductReadRequest $request): JsonResponse { return $this->sendResponseForResource(ProductResource::make($request->getProduct()), __('Product fetched')); } + + public function indexMy(ProductSearchMyRequest $request): JsonResponse + { + $results = $this->productService->searchMy($request->getCriteria(), $request->getPage(), $request->getOrder()); + + return $this->sendResponseForResource(MyProductResource::collection($results)); + } } diff --git a/src/Http/Requests/ProductSearchMyRequest.php b/src/Http/Requests/ProductSearchMyRequest.php new file mode 100644 index 0000000..d9a51a3 --- /dev/null +++ b/src/Http/Requests/ProductSearchMyRequest.php @@ -0,0 +1,43 @@ + ['sometimes', Rule::in(ProductType::getValues())], + 'active' => ['sometimes', 'boolean'], + ]; + } + + public function getCriteria(): ProductSearchMyCriteriaDto + { + return ProductSearchMyCriteriaDto::instantiateFromRequest($this); + } + + public function getPage(): PageDto + { + return PageDto::instantiateFromRequest($this); + } + + public function getOrder(): OrderDto + { + return OrderDto::instantiateFromRequest($this); + } +} diff --git a/src/Http/Resources/MyProductResource.php b/src/Http/Resources/MyProductResource.php new file mode 100644 index 0000000..a8cc64f --- /dev/null +++ b/src/Http/Resources/MyProductResource.php @@ -0,0 +1,76 @@ +users()->where('user_id', $request->user()->getKey())->first()?->pivot; + + return [ + 'id' => $this->getKey(), + 'type' => $this->type, + 'name' => $this->name, + 'is_active' => !$productUserPivot?->end_date || $productUserPivot?->end_date >= Carbon::now(), + 'end_date' => $productUserPivot?->end_date, + 'status' => $productUserPivot?->status, + 'productables' => $this->productables->map(fn(ProductProductable $productProductable) => [ + 'productable_class' => $productProductable->productable_type, + 'productable_id' => $productProductable->productable_id + ]), + ]; + } +} diff --git a/src/Http/Swagger/ProductSwagger.php b/src/Http/Swagger/ProductSwagger.php index 01db642..65239be 100644 --- a/src/Http/Swagger/ProductSwagger.php +++ b/src/Http/Swagger/ProductSwagger.php @@ -2,8 +2,8 @@ namespace EscolaLms\Cart\Http\Swagger; -use EscolaLms\Cart\Http\Requests\ProductListRequest; use EscolaLms\Cart\Http\Requests\ProductReadRequest; +use EscolaLms\Cart\Http\Requests\ProductSearchMyRequest; use EscolaLms\Cart\Http\Requests\ProductSearchRequest; use Illuminate\Http\JsonResponse; @@ -144,6 +144,95 @@ interface ProductSwagger */ public function index(ProductSearchRequest $request): JsonResponse; + /** + * @OA\Get( + * path="/api/products/my", + * description="Search my products", + * tags={"Products"}, + * security={ + * {"passport": {}}, + * }, + * @OA\Parameter( + * name="order_by", + * required=false, + * in="query", + * @OA\Schema( + * type="string", + * enum={"created_at","updated_at","id"} + * ), + * ), + * @OA\Parameter( + * name="order", + * required=false, + * in="query", + * @OA\Schema( + * type="string", + * enum={"ASC", "DESC"} + * ), + * ), + * @OA\Parameter( + * name="page", + * description="Pagination Page Number", + * required=false, + * in="query", + * @OA\Schema( + * type="integer", + * default=1, + * ), + * ), + * @OA\Parameter( + * name="per_page", + * description="Pagination Per Page", + * required=false, + * in="query", + * @OA\Schema( + * type="integer", + * default=15, + * ), + * ), + * @OA\Parameter( + * name="type", + * description="Type (`single`, `bundle`, `subscription`, `subscription-all-in`)", + * required=false, + * in="query", + * @OA\Schema( + * type="string", + * ), + * ), + * @OA\Response( + * response=200, + * description="successful operation", + * @OA\MediaType( + * mediaType="application/json", + * @OA\Schema( + * type="object", + * @OA\Property( + * property="success", + * type="boolean" + * ), + * @OA\Property( + * property="data", + * type="array", + * @OA\Items(ref="#/components/schemas/MyProductResource") + * ), + * @OA\Property( + * property="message", + * type="string" + * ) + * ) + * ), + * ), + * @OA\Response( + * response=422, + * description="Bad request", + * @OA\MediaType( + * mediaType="application/json" + * ) + * ) + * ) + */ + public function indexMy(ProductSearchMyRequest $request): JsonResponse; + /** * @OA\Get( * path="/api/products/{id}", diff --git a/src/Models/Product.php b/src/Models/Product.php index 3ba8026..1ed72a5 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -139,7 +139,9 @@ public function productables(): HasMany public function users(): BelongsToMany { - return $this->belongsToMany(User::class, 'products_users')->using(ProductUser::class)->withPivot('quantity'); + return $this->belongsToMany(User::class, 'products_users') + ->using(ProductUser::class) + ->withPivot(['quantity', 'end_date', 'status']); } public function tags(): MorphMany diff --git a/src/Policies/ProductPolicy.php b/src/Policies/ProductPolicy.php index dd23fb5..8dfb404 100644 --- a/src/Policies/ProductPolicy.php +++ b/src/Policies/ProductPolicy.php @@ -24,6 +24,11 @@ public function viewAny(User $user) return $user->can(CartPermissionsEnum::LIST_ALL_PRODUCTS); } + public function viewMy(User $user) + { + return $user->can(CartPermissionsEnum::LIST_PURCHASABLE_PRODUCTS); + } + public function viewPurchasable(?User $user = null) { return true; diff --git a/src/Services/Contracts/ProductServiceContract.php b/src/Services/Contracts/ProductServiceContract.php index 5127a27..4caeb32 100644 --- a/src/Services/Contracts/ProductServiceContract.php +++ b/src/Services/Contracts/ProductServiceContract.php @@ -3,6 +3,8 @@ namespace EscolaLms\Cart\Services\Contracts; use EscolaLms\Cart\Contracts\Productable; +use EscolaLms\Cart\Dtos\PageDto; +use EscolaLms\Cart\Dtos\ProductSearchMyCriteriaDto; use EscolaLms\Cart\Dtos\ProductsSearchDto; use EscolaLms\Cart\Models\Product; use EscolaLms\Cart\Models\ProductProductable; @@ -44,4 +46,5 @@ public function detachProductableFromUser(Productable $productable, User $user, public function productableIsOwnedByUserThroughProduct(Productable $productable, User $user): bool; public function canDetachProductableFromUser(Productable $productable, User $user): bool; + public function searchMy(ProductSearchMyCriteriaDto $dto, PageDto $pageDto, OrderDto $orderDto): LengthAwarePaginator; } diff --git a/src/Services/ProductService.php b/src/Services/ProductService.php index eab6446..e0c8094 100644 --- a/src/Services/ProductService.php +++ b/src/Services/ProductService.php @@ -3,6 +3,8 @@ namespace EscolaLms\Cart\Services; use EscolaLms\Cart\Contracts\Productable; +use EscolaLms\Cart\Dtos\PageDto; +use EscolaLms\Cart\Dtos\ProductSearchMyCriteriaDto; use EscolaLms\Cart\Dtos\ProductsSearchDto; use EscolaLms\Cart\Enums\ConstantEnum; use EscolaLms\Cart\Enums\ProductType; @@ -17,6 +19,7 @@ use EscolaLms\Cart\Services\Contracts\ProductServiceContract; use EscolaLms\Core\Dtos\OrderDto; use EscolaLms\Core\Models\User; +use EscolaLms\Core\Repositories\Criteria\Criterion; use EscolaLms\Files\Helpers\FileHelper; use Exception; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -548,6 +551,21 @@ public function canDetachProductableFromUser(Productable $productable, User $use return !$this->productableIsOwnedByUserThroughProduct($productable, $user); } + public function searchMy(ProductSearchMyCriteriaDto $dto, PageDto $pageDto, OrderDto $orderDto): LengthAwarePaginator + { + $query = Product::query(); + + foreach ($dto->toArray() as $criterion) { + if ($criterion instanceof Criterion) { + $query = $criterion->apply($query); + } + } + + return $query + ->orderBy($orderDto->getOrderBy() ?? 'id', $orderDto->getOrder() ?? 'desc') + ->paginate($pageDto->getPerPage()); + } + private function productQuantityInCart(User $user, Product $product): int { $cart = Cart::where('user_id', $user->getAuthIdentifier())->latest()->firstOrCreate([ diff --git a/src/routes.php b/src/routes.php index ef0d7fa..aae7976 100644 --- a/src/routes.php +++ b/src/routes.php @@ -46,6 +46,10 @@ }); Route::group(['prefix' => 'api/products'], function () { + Route::group(['prefix' => 'my', 'middleware' => ['auth:api']], function () { + Route::get('/', [ProductApiController::class, 'indexMy']); + }); + Route::get('/{id}', [ProductApiController::class, 'read']); Route::get('/', [ProductApiController::class, 'index']); }); diff --git a/tests/API/ProductApiTest.php b/tests/API/ProductApiTest.php index f99de2f..23ec46d 100644 --- a/tests/API/ProductApiTest.php +++ b/tests/API/ProductApiTest.php @@ -21,6 +21,7 @@ use EscolaLms\Templates\Tests\Mock\TestChannel; use EscolaLms\Templates\Tests\Mock\TestUserVariables; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Event; use Illuminate\Testing\TestResponse; @@ -229,4 +230,94 @@ public function testProductGrossPrice(): void 'gross_price' => 1230, ]); } + + public function test_search_my_products_unauthorized(): void + { + $this->getJson('api/products/my') + ->assertUnauthorized(); + } + + /** + * @dataProvider myProductsFilterDataProvider + */ + public function test_search_my_products(array $filters, callable $generator, int $filterCount): void + { + $user = $this->makeStudent(); + $generator($user)->each(fn($factory) => $factory->create()); + + $this->actingAs($user, 'api') + ->getJson('api/products/my?' . http_build_query($filters)) + ->assertOk() + ->assertJsonCount($filterCount, 'data') + ->assertJsonStructure(['data' => [[ + 'id', + 'type', + 'name', + 'is_active', + 'end_date', + 'end_date', + 'productables', + ]]]); + } + + public function myProductsFilterDataProvider(): array + { + return [ + [ + 'filter' => [], + 'data' => (function (User $user) { + $tasks = collect(); + $tasks->push(Product::factory()->count(5)->hasAttached(User::factory()->count(2))); + $tasks->push(Product::factory()->count(3)->hasAttached($user)); + + return $tasks; + }), + 'filterCount' => 3, + ], + [ + 'filter' => [ + 'type' => 'subscription', + ], + 'data' => (function (User $user) { + $tasks = collect(); + $tasks->push(Product::factory()->count(5)->hasAttached(User::factory()->count(2))); + $tasks->push(Product::factory()->count(2)->hasAttached($user)); + $tasks->push(Product::factory()->state(['type' => 'subscription'])->count(1)->hasAttached($user)); + + return $tasks; + }), + 'filterCount' => 1, + ], + [ + 'filter' => [ + 'active' => false, + ], + 'data' => (function (User $user) { + $tasks = collect(); + $tasks->push(Product::factory()->count(5)->hasAttached(User::factory()->count(2))); + $tasks->push(Product::factory()->count(3)->hasAttached($user, ['end_date' => Carbon::now()->addMonth()])); + $tasks->push(Product::factory()->count(1)->hasAttached($user, ['end_date' => null])); + $tasks->push(Product::factory()->count(2)->hasAttached($user, ['end_date' => Carbon::now()->subMonth()])); + + return $tasks; + }), + 'filterCount' => 2, + ], + [ + 'filter' => [ + 'active' => true, + ], + 'data' => (function (User $user) { + $tasks = collect(); + $tasks->push(Product::factory()->count(5)->hasAttached(User::factory()->count(2))); + $tasks->push(Product::factory()->count(3)->hasAttached($user, ['end_date' => Carbon::now()->addMonth()])); + $tasks->push(Product::factory()->count(1)->hasAttached($user, ['end_date' => null])); + $tasks->push(Product::factory()->count(2)->hasAttached($user, ['end_date' => Carbon::now()->subMonth()])); + + return $tasks; + }), + 'filterCount' => 4, + ], + ]; + } }