diff --git a/backend/app/Actions/AddProductToCartAction.php b/backend/app/Actions/AddProductToCartAction.php new file mode 100644 index 0000000..99aac55 --- /dev/null +++ b/backend/app/Actions/AddProductToCartAction.php @@ -0,0 +1,46 @@ +carts()->active()->firstOrCreate([ + 'status' => CartStatus::Active, + ]); + + $price = Product::query()->whereId($cartData->productId)->value('actual_price'); + + $productInCart = $cart->products()->find($cartData->productId); + + if ($productInCart) { + $newQuantity = $productInCart->pivot->quantity + $cartData->quantity; + + $cart->products()->updateExistingPivot($cartData->productId, [ + 'quantity' => $newQuantity, + 'price' => $price, + ]); + + } else { + $cart->products()->attach($cartData->productId, [ + 'quantity' => $cartData->quantity, + 'price' => $price, + ]); + } + + return CartDTO::fromModel($cart->load(['products' => function ($query) { + $query->withPivot('quantity', 'price'); + }])); + + } +} diff --git a/backend/app/Actions/GetActiveUserCartAction.php b/backend/app/Actions/GetActiveUserCartAction.php new file mode 100644 index 0000000..9fd8a7b --- /dev/null +++ b/backend/app/Actions/GetActiveUserCartAction.php @@ -0,0 +1,17 @@ +carts()->active()->withProducts()->first()); + } +} diff --git a/backend/app/Actions/RemoveProductFromCartAction.php b/backend/app/Actions/RemoveProductFromCartAction.php new file mode 100644 index 0000000..b9ddceb --- /dev/null +++ b/backend/app/Actions/RemoveProductFromCartAction.php @@ -0,0 +1,21 @@ +carts()->active()->sole(); + $cart->products()->detach($productId); + + return $this->activeCartAction->execute($user); + } +} diff --git a/backend/app/Actions/UpdateProductInCartAction.php b/backend/app/Actions/UpdateProductInCartAction.php new file mode 100644 index 0000000..9bb4d8f --- /dev/null +++ b/backend/app/Actions/UpdateProductInCartAction.php @@ -0,0 +1,34 @@ +carts()->active()->sole(); + + $productInCart = $cart->products()->find($cartData->productId); + + throw_if($productInCart === null, new InvalidArgumentException('Product not found')); + + $cart->products()->updateExistingPivot($cartData->productId, [ + 'quantity' => $cartData->quantity, + ]); + + return CartDTO::fromModel($cart->load(['products' => function ($query) { + $query->withPivot('quantity', 'price'); + }])); + + } +} diff --git a/backend/app/Data/AddToCartDTO.php b/backend/app/Data/AddToCartDTO.php new file mode 100644 index 0000000..2035573 --- /dev/null +++ b/backend/app/Data/AddToCartDTO.php @@ -0,0 +1,33 @@ + + */ + public function toArray(): array + { + return [ + 'productId' => $this->productId, + 'quantity' => $this->quantity, + ]; + } + + public static function fromRequest(FormRequest $request): InputDataTransferObject + { + return new self( + productId: $request->productId, + quantity: $request->quantity + ); + } +} diff --git a/backend/app/Data/CartDTO.php b/backend/app/Data/CartDTO.php new file mode 100644 index 0000000..f618ccd --- /dev/null +++ b/backend/app/Data/CartDTO.php @@ -0,0 +1,49 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'itemsCount' => $this->itemsCount, + 'totalPrice' => $this->totalPrice, + 'items' => $this->items, + ]; + } + + public static function fromModel(Cart $cart) + { + return new self( + id: $cart->id, + itemsCount: $cart->products->count(), + totalPrice: $cart->products->sum(fn ($product) => $product->pivot->price * $product->pivot->quantity), + items: $cart->products->map(fn ($product) => new CartItemDTO( + id: $product->id, + title: $product->title, + quantity: $product->pivot->quantity, + price: $product->actual_price, + subtotal: $product->actual_price * $product->pivot->quantity, + image: $product->images->first()->path, + ))->toArray() + ); + } +} diff --git a/backend/app/Data/CartItemDTO.php b/backend/app/Data/CartItemDTO.php new file mode 100644 index 0000000..8e024ff --- /dev/null +++ b/backend/app/Data/CartItemDTO.php @@ -0,0 +1,32 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'quantity' => $this->quantity, + 'price' => $this->price, + 'subtotal' => $this->subtotal, + 'image' => $this->image, + ]; + } +} diff --git a/backend/app/Enums/CartStatus.php b/backend/app/Enums/CartStatus.php new file mode 100644 index 0000000..c06b8cc --- /dev/null +++ b/backend/app/Enums/CartStatus.php @@ -0,0 +1,11 @@ +execute($addToCartData, Auth::user()); + + return new CartResource($cart); + + } + + /** + * Display the specified resource. + */ + public function show(GetActiveUserCartAction $action) + { + return new CartResource($action->execute(Auth::user())); + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateProductInCartRequest $request, UpdateProductInCartAction $action) + { + $updateCartData = AddToCartDTO::fromRequest($request); + $cart = $action->execute($updateCartData, $request->user()); + + return new CartResource($cart); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(RemoveProductFromCartRequest $request, RemoveProductFromCartAction $action) + { + $user = $request->user(); + try { + $cart = $action->execute($request->productId, $user); + + return new CartResource($cart); + } catch (ModelNotFoundException $e) { + + Log::error('No active cart found when removing a product from cart.', [ + 'user' => $user->id, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'message' => 'No active cart found.', + ], 404); + + } catch (MultipleRecordsFoundException $e) { + Log::error('Multiple active carts found for the user', [ + 'user' => $user->id, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'message' => 'Multiple Active carts found.', + ], 409); + } + + } +} diff --git a/backend/app/Http/Requests/AddProductToCartRequest.php b/backend/app/Http/Requests/AddProductToCartRequest.php new file mode 100644 index 0000000..12615f5 --- /dev/null +++ b/backend/app/Http/Requests/AddProductToCartRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'productId' => 'required|exists:products,id', + 'quantity' => 'required|numeric|min:1', + ]; + } +} diff --git a/backend/app/Http/Requests/RemoveProductFromCartRequest.php b/backend/app/Http/Requests/RemoveProductFromCartRequest.php new file mode 100644 index 0000000..d388f6d --- /dev/null +++ b/backend/app/Http/Requests/RemoveProductFromCartRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'productId' => ['required', 'exists:products,id'], + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateProductInCartRequest.php b/backend/app/Http/Requests/UpdateProductInCartRequest.php new file mode 100644 index 0000000..adbc4d9 --- /dev/null +++ b/backend/app/Http/Requests/UpdateProductInCartRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'productId' => ['required', 'exists:products,id'], + 'quantity' => ['required', 'min:0', 'max:10'], + ]; + } +} diff --git a/backend/app/Http/Resources/CartItemCollection.php b/backend/app/Http/Resources/CartItemCollection.php new file mode 100644 index 0000000..69073c5 --- /dev/null +++ b/backend/app/Http/Resources/CartItemCollection.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return parent::toArray($request); + } +} diff --git a/backend/app/Http/Resources/CartItemResource.php b/backend/app/Http/Resources/CartItemResource.php new file mode 100644 index 0000000..2510e20 --- /dev/null +++ b/backend/app/Http/Resources/CartItemResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'title' => $this->resource->title, + 'quantity' => $this->resource->quantity, + 'price' => $this->resource->price, + 'subtotal' => $this->resource->subtotal, + 'image' => Storage::disk('public')->url($this->resource->image), + ]; + } +} diff --git a/backend/app/Http/Resources/CartResource.php b/backend/app/Http/Resources/CartResource.php new file mode 100644 index 0000000..99ab883 --- /dev/null +++ b/backend/app/Http/Resources/CartResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'itemsCount' => $this->resource->itemsCount, + 'totalPrice' => $this->resource->totalPrice, + 'items' => CartItemResource::collection($this->resource->items), + ]; + } +} diff --git a/backend/app/Models/Cart.php b/backend/app/Models/Cart.php new file mode 100644 index 0000000..e50d6f2 --- /dev/null +++ b/backend/app/Models/Cart.php @@ -0,0 +1,46 @@ + CartStatus::class, + ]; + } + + public function products() + { + return $this->belongsToMany(Product::class) + ->withPivot('price', 'quantity') + ->withTimestamps(); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + #[Scope] + protected function active(Builder $query) + { + return $query->where('status', CartStatus::Active); + } + + #[Scope] + protected function withProducts(Builder $query) + { + return $query->with(['products' => function ($product) { + $product->withPivot('quantity', 'price'); + }]); + } +} diff --git a/backend/app/Models/Product.php b/backend/app/Models/Product.php index ef200b6..b6f84ab 100644 --- a/backend/app/Models/Product.php +++ b/backend/app/Models/Product.php @@ -36,6 +36,11 @@ public function favoritedBy(): BelongsToMany return $this->belongsToMany(User::class, 'favorite_products'); } + public function carts() + { + return $this->belongsToMany(Cart::class); + } + #[Scope] protected function active(Builder $query): Builder { diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index a0ab8b4..7c68aa5 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -62,4 +62,9 @@ public function hasFavorited(Product $product): bool { return $this->favoriteProducts()->where('product_id', $product->id)->exists(); } + + public function carts() + { + return $this->hasMany(Cart::class); + } } diff --git a/backend/database/migrations/2026_03_06_054113_create_carts_table.php b/backend/database/migrations/2026_03_06_054113_create_carts_table.php new file mode 100644 index 0000000..8e68e1a --- /dev/null +++ b/backend/database/migrations/2026_03_06_054113_create_carts_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(User::class); + $table->enum('status', array_column(CartStatus::cases(), 'value')); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/backend/database/migrations/2026_03_06_055910_cart_product.php b/backend/database/migrations/2026_03_06_055910_cart_product.php new file mode 100644 index 0000000..2bf77d1 --- /dev/null +++ b/backend/database/migrations/2026_03_06_055910_cart_product.php @@ -0,0 +1,35 @@ +id(); + $table->foreignIdFor(Cart::class); + $table->foreignIdFor(Product::class); + $table->decimal('price', 10, 2); + $table->integer('quantity'); + $table->timestamps(); + + $table->unique(['cart_id', 'product_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cart_product'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 311580f..c1dc546 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,7 @@ creatable() + ->destroyable(); }); Route::get('/categories', [ProductCategoryController::class, 'index']); Route::apiResource('products', ProductController::class);