diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 87db693..6d60e52 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -54,5 +54,7 @@ jobs: DB_PASSWORD_PROD: ${{ secrets.PROD_DB_PASSWORD }} REDIS_PASSWORD_PROD: ${{ secrets.PROD_REDIS_PASSWORD }} + JWT_SECRET_PROD: ${{ secrets.PROD_JWT_SECRET }} DB_PASSWORD_DEV: ${{ secrets.DEV_DB_PASSWORD }} - REDIS_PASSWORD_DEV: ${{ secrets.DEV_REDIS_PASSWORD }} \ No newline at end of file + REDIS_PASSWORD_DEV: ${{ secrets.DEV_REDIS_PASSWORD }} + JWT_SECRET_DEV: ${{ secrets.DEV_JWT_SECRET }} \ No newline at end of file diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php new file mode 100644 index 0000000..1538be1 --- /dev/null +++ b/app/Http/Controllers/CartController.php @@ -0,0 +1,110 @@ +attributes->get('user_id'); + } + + private function activeCart(int $userId): Cart + { + return Cart::query()->firstOrCreate( + ['user_id' => $userId, 'status' => 'active'], + ['user_id' => $userId, 'status' => 'active'] + ); + } + + public function show(Request $request) + { + $cart = $this->activeCart($this->userId($request)); + $cart->load('items'); + + $total = $cart->items->sum(fn ($i) => $i->unit_price_snapshot * $i->qty); + + return response()->json([ + 'data' => [ + 'id' => $cart->id, + 'items' => $cart->items, + 'total_price' => (int) $total, + ], + ]); + } + + public function addItem(Request $request, ProductClient $products) + { + $data = $request->validate([ + 'product_id' => ['required', 'integer', 'min:1'], + 'qty' => ['required', 'integer', 'min:1', 'max:999'], + ]); + + $userId = $this->userId($request); + $cart = $this->activeCart($userId); + + $p = $products->getProduct((int) $data['product_id']); + + $item = CartItem::query()->where('cart_id', $cart->id) + ->where('product_id', (int) $data['product_id']) + ->first(); + + if ($item) { + $item->qty = $item->qty + (int) $data['qty']; + // osveži snapshot (optional) + $item->name_snapshot = $p['name']; + $item->unit_price_snapshot = $p['price']; + $item->save(); + } else { + $item = CartItem::query()->create([ + 'cart_id' => $cart->id, + 'product_id' => (int) $p['id'], + 'name_snapshot' => $p['name'], + 'unit_price_snapshot' => (int) $p['price'], + 'qty' => (int) $data['qty'], + ]); + } + + return response()->json(['data' => $item], 201); + } + + public function updateItem(Request $request, int $itemId) + { + $data = $request->validate([ + 'qty' => ['required', 'integer', 'min:1', 'max:999'], + ]); + + $userId = $this->userId($request); + $cart = $this->activeCart($userId); + + $item = CartItem::query() + ->where('cart_id', $cart->id) + ->where('id', $itemId) + ->firstOrFail(); + + $item->qty = (int) $data['qty']; + $item->save(); + + return response()->json(['data' => $item]); + } + + public function removeItem(Request $request, int $itemId) + { + $userId = $this->userId($request); + $cart = $this->activeCart($userId); + + $item = CartItem::query() + ->where('cart_id', $cart->id) + ->where('id', $itemId) + ->firstOrFail(); + + $item->delete(); + + return response()->json(['status' => 'ok']); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/OrderController.php b/app/Http/Controllers/OrderController.php new file mode 100644 index 0000000..ebb4952 --- /dev/null +++ b/app/Http/Controllers/OrderController.php @@ -0,0 +1,91 @@ +attributes->get('user_id'); + } + + private function activeCart(int $userId): Cart + { + return Cart::query()->firstOrCreate( + ['user_id' => $userId, 'status' => 'active'], + ['user_id' => $userId, 'status' => 'active'] + ); + } + + public function createFromCart(Request $request) + { + $userId = $this->userId($request); + $cart = $this->activeCart($userId); + $cart->load('items'); + + if ($cart->items->isEmpty()) { + return response()->json(['message' => 'Cart is empty'], 422); + } + + $order = DB::transaction(function () use ($cart, $userId) { + $total = $cart->items->sum(fn ($i) => $i->unit_price_snapshot * $i->qty); + + $order = Order::query()->create([ + 'user_id' => $userId, + 'status' => 'pending_payment', + 'total_price' => (int) $total, + ]); + + foreach ($cart->items as $item) { + OrderItem::query()->create([ + 'order_id' => $order->id, + 'product_id' => $item->product_id, + 'name_snapshot' => $item->name_snapshot, + 'unit_price_snapshot' => $item->unit_price_snapshot, + 'qty' => $item->qty, + ]); + } + + // počistimo košarico + $cart->items()->delete(); + + return $order; + }); + + $order->load('items'); + + return response()->json(['data' => $order], 201); + } + + public function index(Request $request) + { + $userId = $this->userId($request); + + $orders = Order::query() + ->where('user_id', $userId) + ->orderByDesc('id') + ->get(); + + return response()->json(['data' => $orders]); + } + + public function show(Request $request, int $orderId) + { + $userId = $this->userId($request); + + $order = Order::query() + ->where('user_id', $userId) + ->where('id', $orderId) + ->firstOrFail(); + + $order->load('items'); + + return response()->json(['data' => $order]); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/JwtAuth.php b/app/Http/Middleware/JwtAuth.php new file mode 100644 index 0000000..e512de2 --- /dev/null +++ b/app/Http/Middleware/JwtAuth.php @@ -0,0 +1,37 @@ +header('Authorization', ''); + + if (!str_starts_with($authHeader, 'Bearer ')) { + return response()->json(['message' => 'Missing Bearer token'], 401); + } + + $token = trim(substr($authHeader, 7)); + + try { + $claims = app(JwtService::class)->decode($token); + $userId = $claims['sub'] ?? null; + + if (!$userId) { + return response()->json(['message' => 'Invalid token'], 401); + } + + // nastavimo user_id na request (ker v tem servisu nimamo users tabele) + $request->attributes->set('user_id', (int) $userId); + + return $next($request); + } catch (\Throwable $e) { + return response()->json(['message' => 'Invalid or expired token'], 401); + } + } +} \ No newline at end of file diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 0000000..944a39d --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,16 @@ +hasMany(CartItem::class); + } +} \ No newline at end of file diff --git a/app/Models/CartItem.php b/app/Models/CartItem.php new file mode 100644 index 0000000..8f6ad45 --- /dev/null +++ b/app/Models/CartItem.php @@ -0,0 +1,22 @@ +belongsTo(Cart::class); + } +} \ No newline at end of file diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..73b6591 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,16 @@ +hasMany(OrderItem::class); + } +} \ No newline at end of file diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php new file mode 100644 index 0000000..69608b3 --- /dev/null +++ b/app/Models/OrderItem.php @@ -0,0 +1,22 @@ +belongsTo(Order::class); + } +} \ No newline at end of file diff --git a/app/Services/JwtService.php b/app/Services/JwtService.php new file mode 100644 index 0000000..eba1abd --- /dev/null +++ b/app/Services/JwtService.php @@ -0,0 +1,20 @@ +get($url); + + if (!$resp->successful()) { + throw new \RuntimeException("Product not found or product-service error (status {$resp->status()})."); + } + + $json = $resp->json(); + + // pričakujemo { data: { id, name, price, ... } } + $p = $json['data'] ?? null; + if (!$p || !isset($p['name'], $p['price'], $p['id'])) { + throw new \RuntimeException('Unexpected product-service response.'); + } + + return [ + 'id' => (int) $p['id'], + 'name' => (string) $p['name'], + 'price' => (int) $p['price'], + ]; + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c5..75b58e0 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,9 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'jwt' => \App\Http\Middleware\JwtAuth::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 52a3a8a..bed1eb0 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "firebase/php-jwt": "^7.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1" }, diff --git a/composer.lock b/composer.lock index 6d1ffb6..f4e9b09 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": "c514d8f7b9fc5970bdd94287905ef584", + "content-hash": "1d3e782b8af4b1f8a0ce91cd12381a02", "packages": [ { "name": "brick/math", @@ -508,6 +508,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.2", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + }, + "time": "2025-12-16T22:17:28+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", diff --git a/config/jwt.php b/config/jwt.php new file mode 100644 index 0000000..c9ad772 --- /dev/null +++ b/config/jwt.php @@ -0,0 +1,5 @@ + env('JWT_SECRET'), +]; \ No newline at end of file diff --git a/config/services.php b/config/services.php index 6a90eb8..6e01adf 100644 --- a/config/services.php +++ b/config/services.php @@ -34,5 +34,7 @@ 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), ], ], - + 'products' => [ + 'base_url' => env('PRODUCT_SERVICE_BASE_URL'), + ], ]; diff --git a/database/migrations/2026_01_28_142217_create_carts_table.php b/database/migrations/2026_01_28_142217_create_carts_table.php new file mode 100644 index 0000000..c418539 --- /dev/null +++ b/database/migrations/2026_01_28_142217_create_carts_table.php @@ -0,0 +1,24 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->string('status', 30)->default('active'); + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_01_28_142223_create_cart_items_table.php b/database/migrations/2026_01_28_142223_create_cart_items_table.php new file mode 100644 index 0000000..595da77 --- /dev/null +++ b/database/migrations/2026_01_28_142223_create_cart_items_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('cart_id')->constrained('carts')->cascadeOnDelete(); + + $table->unsignedBigInteger('product_id'); + $table->string('name_snapshot', 180); + $table->unsignedInteger('unit_price_snapshot'); // isto kot product.price + $table->unsignedInteger('qty'); + + $table->timestamps(); + + $table->unique(['cart_id', 'product_id']); // 1 product = 1 item (qty se povečuje) + }); + } + + public function down(): void + { + Schema::dropIfExists('cart_items'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_01_28_142309_create_orders_table.php b/database/migrations/2026_01_28_142309_create_orders_table.php new file mode 100644 index 0000000..b2a5aee --- /dev/null +++ b/database/migrations/2026_01_28_142309_create_orders_table.php @@ -0,0 +1,27 @@ +id(); + + $table->unsignedBigInteger('user_id'); + $table->string('status', 30)->default('pending_payment'); + + $table->unsignedInteger('total_price'); + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_01_28_142323_create_order_items_table.php b/database/migrations/2026_01_28_142323_create_order_items_table.php new file mode 100644 index 0000000..a8a2b65 --- /dev/null +++ b/database/migrations/2026_01_28_142323_create_order_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + + $table->unsignedBigInteger('product_id'); + $table->string('name_snapshot', 180); + $table->unsignedInteger('unit_price_snapshot'); + $table->unsignedInteger('qty'); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_items'); + } +}; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 19347e5..62b2896 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,7 @@ json([ - [ - 'id' => 1, - 'name' => 'Order 1', - 'total_cost' => 50.90, - 'currency' => 'EUR', - ], - [ - 'id' => 2, - 'name' => 'Order 2', - 'total_cost' => 40.90, - 'currency' => 'EUR', - ], - [ - 'id' => 3, - 'name' => 'Order 3', - 'total_cost' => 30.90, - 'currency' => 'EUR', - ], - ]); + +Route::middleware('jwt')->group(function () { + // cart + Route::get('/cart', [CartController::class, 'show']); + Route::post('/cart/items', [CartController::class, 'addItem']); + Route::patch('/cart/items/{itemId}', [CartController::class, 'updateItem']); + Route::delete('/cart/items/{itemId}', [CartController::class, 'removeItem']); + + // orders + Route::post('/items', [OrderController::class, 'createFromCart']); + Route::get('/items', [OrderController::class, 'index']); + Route::get('/items/{orderId}', [OrderController::class, 'show']); });