From 86a209584b2e72ed105878a6bd67847a5c75c400 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 16:29:49 +0700 Subject: [PATCH 01/15] feat: Add GET /payments/order/:order_id endpoint with Stripe client_secret Adds PaymentCheckoutResponse DTO that includes client_secret so the frontend can call stripe.confirmPayment() via Stripe.js after order placement. Adds GetPaymentByOrderID to service interface and implementation; ownership check (UserID match) is enforced before returning the secret. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/domain/payment.go | 32 +++++++++++++++++++ .../internal/domain/service.go | 1 + .../internal/handler/payment_handler.go | 22 +++++++++++++ .../internal/route/payment_route.go | 1 + .../internal/service/payment_service.go | 13 ++++++++ 5 files changed, 69 insertions(+) diff --git a/services/payment-service/internal/domain/payment.go b/services/payment-service/internal/domain/payment.go index 5151f85..808caf2 100644 --- a/services/payment-service/internal/domain/payment.go +++ b/services/payment-service/internal/domain/payment.go @@ -62,6 +62,38 @@ func (p *Payment) ToResponse() *PaymentResponse { } } +// PaymentCheckoutResponse is returned to the frontend after order placement. +// It includes client_secret so the frontend can confirm the payment via Stripe.js. +type PaymentCheckoutResponse struct { + ID uuid.UUID `json:"id"` + OrderID uuid.UUID `json:"order_id"` + UserID uuid.UUID `json:"user_id"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Status PaymentStatus `json:"status"` + StripePaymentIntentID string `json:"stripe_payment_intent_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + FailureReason string `json:"failure_reason,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (p *Payment) ToCheckoutResponse() *PaymentCheckoutResponse { + return &PaymentCheckoutResponse{ + ID: p.ID, + OrderID: p.OrderID, + UserID: p.UserID, + Amount: p.Amount, + Currency: p.Currency, + Status: p.Status, + StripePaymentIntentID: p.StripePaymentIntentID, + ClientSecret: p.StripeClientSecret, + FailureReason: p.FailureReason, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } +} + // OrderCreatedEvent is the shape of the Kafka message consumed from order-service. // JSON tags must match the Order struct in order-service (ID is published as "id"). type OrderCreatedEvent struct { diff --git a/services/payment-service/internal/domain/service.go b/services/payment-service/internal/domain/service.go index 3c2317e..c48ba81 100644 --- a/services/payment-service/internal/domain/service.go +++ b/services/payment-service/internal/domain/service.go @@ -8,6 +8,7 @@ import ( type PaymentService interface { GetPaymentByID(ctx context.Context, userID, paymentID uuid.UUID) (*PaymentResponse, error) + GetPaymentByOrderID(ctx context.Context, userID, orderID uuid.UUID) (*PaymentCheckoutResponse, error) HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error HandleStripeWebhook(ctx context.Context, payload []byte, signature string) error } diff --git a/services/payment-service/internal/handler/payment_handler.go b/services/payment-service/internal/handler/payment_handler.go index b79ba40..f46d90e 100644 --- a/services/payment-service/internal/handler/payment_handler.go +++ b/services/payment-service/internal/handler/payment_handler.go @@ -42,6 +42,28 @@ func (h *PaymentHandler) GetPaymentByID(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true, "data": payment}) } +func (h *PaymentHandler) GetPaymentByOrderID(c *gin.Context) { + userID, ok := getUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) + return + } + + orderID, err := uuid.Parse(c.Param("order_id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid order id"}) + return + } + + payment, err := h.service.GetPaymentByOrderID(c.Request.Context(), userID, orderID) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": payment}) +} + func (h *PaymentHandler) HandleStripeWebhook(c *gin.Context) { rawBody, exists := c.Get(middleware.RawBodyKey) if !exists { diff --git a/services/payment-service/internal/route/payment_route.go b/services/payment-service/internal/route/payment_route.go index 50020a1..bcc077d 100644 --- a/services/payment-service/internal/route/payment_route.go +++ b/services/payment-service/internal/route/payment_route.go @@ -10,5 +10,6 @@ import ( func RegisterPaymentRoutes(router *gin.Engine, paymentHandler *handler.PaymentHandler) { api := router.Group("/") api.GET("/payments/:id", paymentHandler.GetPaymentByID) + api.GET("/payments/order/:order_id", paymentHandler.GetPaymentByOrderID) api.POST("/payments/webhook/stripe", middleware.CaptureRawBody(), paymentHandler.HandleStripeWebhook) } diff --git a/services/payment-service/internal/service/payment_service.go b/services/payment-service/internal/service/payment_service.go index 6a31c21..3147c1c 100644 --- a/services/payment-service/internal/service/payment_service.go +++ b/services/payment-service/internal/service/payment_service.go @@ -63,6 +63,19 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, userID, paymentID u return payment.ToResponse(), nil } +func (s *PaymentService) GetPaymentByOrderID(ctx context.Context, userID, orderID uuid.UUID) (*domain.PaymentCheckoutResponse, error) { + payment, err := s.paymentRepo.GetPaymentByOrderID(orderID) + if err != nil { + return nil, err + } + + if payment.UserID != userID { + return nil, domain.ErrForbidden + } + + return payment.ToCheckoutResponse(), nil +} + func (s *PaymentService) HandleOrderCreated(ctx context.Context, event domain.OrderCreatedEvent) error { // Idempotency: skip if payment already exists for this order. existing, err := s.paymentRepo.GetPaymentByOrderID(event.OrderID) From 3fd65c330216c0945a0d13323a5be8b051c01f26 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 16:29:57 +0700 Subject: [PATCH 02/15] fix: Make inventory GET public and add payment/order/:order_id gateway route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /inventory/:product_id no longer requires auth — product pages can now display stock availability without an admin token - PUT /inventory/:product_id remains admin-only - GET /payments/order/:order_id proxied to payment-service with auth required Co-Authored-By: Claude Sonnet 4.6 --- services/api-gateway/routes/router.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/services/api-gateway/routes/router.go b/services/api-gateway/routes/router.go index efcf93b..332219c 100644 --- a/services/api-gateway/routes/router.go +++ b/services/api-gateway/routes/router.go @@ -142,16 +142,20 @@ func Setup(router *gin.Engine, cfg *config.Config) error { payments.Use(middleware.RequestID()) { payments.GET("/:id", requireAuth, toPaymentService) + payments.GET("/order/:order_id", requireAuth, toPaymentService) payments.POST("/webhook/stripe", toPaymentService) } - // Inventory routes — admin only + // Inventory routes — GET is public (product pages show stock); PUT is admin only inventory := api.Group("/inventory") inventory.Use(middleware.RequestID()) - inventory.Use(requireAuth, requireRole) { inventory.GET("/:product_id", toInventoryService) - inventory.PUT("/:product_id", toInventoryService) + } + adminInventory := inventory.Group("") + adminInventory.Use(requireAuth, requireRole) + { + adminInventory.PUT("/:product_id", toInventoryService) } // Generic escape hatch: /api/services/:service/*path -> SERVICE_URL_ From ee3944601e225c6a32266a8424dc6a855112c9c4 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 16:30:03 +0700 Subject: [PATCH 03/15] fix: Align user-service HTTP responses to { success, data } envelope All endpoints now return { success: true, data: {...} } on success and { success: false, error: "..." } on failure, matching the envelope used by every other service in the platform. Login and refresh token responses are unchanged ({ access_token, refresh_token }) as they are auth-protocol responses, not resource envelopes. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/handler/user_handler.go | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/services/user-service/internal/handler/user_handler.go b/services/user-service/internal/handler/user_handler.go index a42f677..c3723ee 100644 --- a/services/user-service/internal/handler/user_handler.go +++ b/services/user-service/internal/handler/user_handler.go @@ -22,7 +22,7 @@ func NewUserHandler(service domain.UserService) *UserHandler { func (h *UserHandler) Register(c *gin.Context) { var req domain.CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) return } @@ -32,7 +32,7 @@ func (h *UserHandler) Register(c *gin.Context) { return } - c.JSON(http.StatusCreated, domain.UserEnvelopeResponse{User: toUserResponse(user)}) + c.JSON(http.StatusCreated, gin.H{"success": true, "data": toUserResponse(user)}) } func (h *UserHandler) Login(c *gin.Context) { @@ -46,7 +46,7 @@ func (h *UserHandler) Login(c *gin.Context) { LoginWithTokens(req *domain.LoginRequest) (*domain.AuthResponse, error) }) if !ok { - c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Error: "service does not support token response"}) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error":"service does not support token response"}) return } @@ -77,7 +77,7 @@ func (h *UserHandler) RefreshToken(c *gin.Context) { } if req.RefreshToken == "" { - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: "refresh token is required"}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error":"refresh token is required"}) return } @@ -85,7 +85,7 @@ func (h *UserHandler) RefreshToken(c *gin.Context) { RefreshTokenWithTokens(req *domain.RefreshTokenRequest) (*domain.AuthResponse, error) }) if !ok { - c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Error: "service does not support token response"}) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error":"service does not support token response"}) return } @@ -116,7 +116,7 @@ func (h *UserHandler) Logout(c *gin.Context) { } if req.RefreshToken == "" { - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: "refresh token is required"}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error":"refresh token is required"}) return } @@ -128,13 +128,13 @@ func (h *UserHandler) Logout(c *gin.Context) { h.clearCookie(c, "access_token") h.clearCookie(c, "refresh_token") - c.JSON(http.StatusOK, domain.MessageResponse{Message: "logged out"}) + c.JSON(http.StatusOK, gin.H{"success": true, "message": "logged out"}) } func (h *UserHandler) GetProfile(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) if !ok { - c.JSON(http.StatusUnauthorized, domain.ErrorResponse{Error: domain.ErrUnauthorized.Error()}) + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) return } @@ -144,19 +144,19 @@ func (h *UserHandler) GetProfile(c *gin.Context) { return } - c.JSON(http.StatusOK, domain.UserEnvelopeResponse{User: toUserResponse(user)}) + c.JSON(http.StatusOK, gin.H{"success": true, "data": toUserResponse(user)}) } func (h *UserHandler) UpdateProfile(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) if !ok { - c.JSON(http.StatusUnauthorized, domain.ErrorResponse{Error: domain.ErrUnauthorized.Error()}) + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) return } var req domain.UpdateUserRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) return } @@ -166,19 +166,19 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { return } - c.JSON(http.StatusOK, domain.UserEnvelopeResponse{User: toUserResponse(user)}) + c.JSON(http.StatusOK, gin.H{"success": true, "data": toUserResponse(user)}) } func (h *UserHandler) AddAddress(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) if !ok { - c.JSON(http.StatusUnauthorized, domain.ErrorResponse{Error: domain.ErrUnauthorized.Error()}) + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) return } var req domain.CreateAddressRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) return } @@ -198,13 +198,13 @@ func (h *UserHandler) AddAddress(c *gin.Context) { return } - c.JSON(http.StatusCreated, domain.AddressEnvelopeResponse{Address: toAddressResponse(createdAddress)}) + c.JSON(http.StatusCreated, gin.H{"success": true, "data": toAddressResponse(createdAddress)}) } func (h *UserHandler) GetAddresses(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) if !ok { - c.JSON(http.StatusUnauthorized, domain.ErrorResponse{Error: domain.ErrUnauthorized.Error()}) + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) return } @@ -220,25 +220,25 @@ func (h *UserHandler) GetAddresses(c *gin.Context) { response = append(response, toAddressResponse(&addr)) } - c.JSON(http.StatusOK, domain.AddressesEnvelopeResponse{Addresses: response}) + c.JSON(http.StatusOK, gin.H{"success": true, "data": response}) } func (h *UserHandler) UpdateAddress(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) if !ok { - c.JSON(http.StatusUnauthorized, domain.ErrorResponse{Error: domain.ErrUnauthorized.Error()}) + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) return } addressID, err := uuid.Parse(c.Param("id")) if err != nil { - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: "invalid address id"}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error":"invalid address id"}) return } var req domain.UpdateAddressRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) return } @@ -248,25 +248,25 @@ func (h *UserHandler) UpdateAddress(c *gin.Context) { return } - c.JSON(http.StatusOK, domain.AddressEnvelopeResponse{Address: toAddressResponse(updatedAddress)}) + c.JSON(http.StatusOK, gin.H{"success": true, "data": toAddressResponse(updatedAddress)}) } func (h *UserHandler) DeleteAddress(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) if !ok { - c.JSON(http.StatusUnauthorized, domain.ErrorResponse{Error: domain.ErrUnauthorized.Error()}) + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) return } addressIDRaw := c.Param("id") if addressIDRaw == "" { - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: "address id is required"}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error":"address id is required"}) return } addressID, err := uuid.Parse(addressIDRaw) if err != nil { - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: "invalid address id"}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error":"invalid address id"}) return } @@ -275,7 +275,7 @@ func (h *UserHandler) DeleteAddress(c *gin.Context) { return } - c.JSON(http.StatusOK, domain.DeleteAddressResponse{Message: "address deleted"}) + c.JSON(http.StatusOK, gin.H{"success": true, "message": "address deleted"}) } func (h *UserHandler) applyCookie(c *gin.Context, cfg domain.CookieConfig) { @@ -293,17 +293,17 @@ func (h *UserHandler) clearCookie(c *gin.Context, name string) { func (h *UserHandler) handleServiceError(c *gin.Context, err error) { switch { case errors.Is(err, domain.ErrInvalidCredentials), errors.Is(err, domain.ErrUnauthorized), errors.Is(err, domain.ErrInvalidToken), errors.Is(err, domain.ErrExpiredToken): - c.JSON(http.StatusUnauthorized, domain.ErrorResponse{Error: err.Error()}) + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": err.Error()}) case errors.Is(err, domain.ErrForbidden): - c.JSON(http.StatusForbidden, domain.ErrorResponse{Error: err.Error()}) + c.JSON(http.StatusForbidden, gin.H{"success": false, "error": err.Error()}) case errors.Is(err, domain.ErrUserNotFound), errors.Is(err, domain.ErrAddressNotFound): - c.JSON(http.StatusNotFound, domain.ErrorResponse{Error: err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) case errors.Is(err, domain.ErrEmailAlreadyExists): - c.JSON(http.StatusConflict, domain.ErrorResponse{Error: err.Error()}) + c.JSON(http.StatusConflict, gin.H{"success": false, "error": err.Error()}) case errors.Is(err, domain.ErrPasswordMismatch): - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) default: - c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Error: err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) } } From 60dc13cc51d5acca39273ba97b59a79fccf12a45 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 16:31:40 +0700 Subject: [PATCH 04/15] docs: Add API curl test guide covering all endpoints Sequential curl commands for auth, user, address, category, product, inventory, cart, order, and payment flows including error cases. Co-Authored-By: Claude Sonnet 4.6 --- API_CURL_TESTS.md | 660 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 API_CURL_TESTS.md diff --git a/API_CURL_TESTS.md b/API_CURL_TESTS.md new file mode 100644 index 0000000..a821cb4 --- /dev/null +++ b/API_CURL_TESTS.md @@ -0,0 +1,660 @@ +# Auron API — curl Test Guide + +All requests go through the API Gateway on `http://localhost:8080`. + +--- + +## Setup + +```bash +BASE=http://localhost:8080/api +``` + +Run the commands below **in order** — later steps depend on tokens and IDs from earlier steps. + +--- + +## 1. Auth + +### Register a customer + +```bash +curl -s -X POST $BASE/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "customer@auron.test", + "password": "password123", + "confirm_password": "password123", + "name": "Test Customer" + }' | jq +``` + +Expected: `{ "success": true, "data": { "id", "email", "name", "role": "customer" } }` + +--- + +### Register an admin + +```bash +curl -s -X POST $BASE/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "admin@auron.test", + "password": "password123", + "confirm_password": "password123", + "name": "Test Admin", + "role": "admin" + }' | jq +``` + +--- + +### Login as customer — save token + +```bash +CUSTOMER_TOKEN=$(curl -s -X POST $BASE/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "customer@auron.test", + "password": "password123" + }' | jq -r '.access_token') + +echo "Customer token: $CUSTOMER_TOKEN" +``` + +--- + +### Login as admin — save token + +```bash +ADMIN_TOKEN=$(curl -s -X POST $BASE/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "admin@auron.test", + "password": "password123" + }' | jq -r '.access_token') + +echo "Admin token: $ADMIN_TOKEN" +``` + +--- + +### Refresh token + +```bash +REFRESH_TOKEN=$(curl -s -X POST $BASE/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "customer@auron.test", + "password": "password123" + }' | jq -r '.refresh_token') + +curl -s -X POST $BASE/auth/refresh \ + -H "Content-Type: application/json" \ + -d "{\"refresh_token\": \"$REFRESH_TOKEN\"}" | jq +``` + +Expected: `{ "access_token": "...", "refresh_token": "..." }` + +--- + +### Logout + +```bash +curl -s -X POST $BASE/auth/logout \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"refresh_token\": \"$REFRESH_TOKEN\"}" | jq +``` + +Expected: `{ "success": true, "message": "logged out" }` + +--- + +## 2. User Profile + +### Get profile + +```bash +curl -s -X GET $BASE/users/me \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +Expected: `{ "success": true, "data": { "id", "email", "name", "role" } }` + +--- + +### Update profile + +```bash +curl -s -X PUT $BASE/users/me \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "name": "Updated Customer Name" }' | jq +``` + +Expected: `{ "success": true, "data": { ... } }` + +--- + +## 3. Addresses + +### Add address + +```bash +curl -s -X POST $BASE/users/me/addresses \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "label": "Home", + "street": "123 Main St", + "city": "Jakarta", + "country": "Indonesia", + "postal_code": "10110", + "is_default": true + }' | jq +``` + +Expected: `{ "success": true, "data": { "id", "label", "street", "city", ... } }` + +```bash +# Save address ID for later +ADDRESS_ID=$(curl -s -X POST $BASE/users/me/addresses \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "label": "Office", + "street": "456 Business Ave", + "city": "Bandung", + "country": "Indonesia", + "is_default": false + }' | jq -r '.data.id') + +echo "Address ID: $ADDRESS_ID" +``` + +--- + +### Get all addresses + +```bash +curl -s -X GET $BASE/users/me/addresses \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +Expected: `{ "success": true, "data": [ ... ] }` + +--- + +### Update address + +```bash +curl -s -X PUT $BASE/users/me/addresses/$ADDRESS_ID \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "city": "Surabaya" }' | jq +``` + +--- + +### Delete address + +```bash +curl -s -X DELETE $BASE/users/me/addresses/$ADDRESS_ID \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +Expected: `{ "success": true, "message": "address deleted" }` + +--- + +## 4. Categories (admin only for write) + +### Get all categories — public + +```bash +curl -s -X GET $BASE/categories | jq +``` + +--- + +### Create category — admin + +```bash +CATEGORY_ID=$(curl -s -X POST $BASE/categories \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Electronics", + "slug": "electronics" + }' | jq -r '.data.id') + +echo "Category ID: $CATEGORY_ID" +``` + +### Create sub-category + +```bash +curl -s -X POST $BASE/categories \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Smartphones\", + \"slug\": \"smartphones\", + \"parent_id\": \"$CATEGORY_ID\" + }" | jq +``` + +--- + +## 5. Products (admin only for write) + +### Create product — admin + +```bash +PRODUCT_ID=$(curl -s -X POST $BASE/products \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"category_id\": \"$CATEGORY_ID\", + \"name\": \"iPhone 15 Pro\", + \"description\": \"Apple iPhone 15 Pro 256GB\", + \"price\": 15999000, + \"is_active\": true + }" | jq -r '.data.id') + +echo "Product ID: $PRODUCT_ID" +``` + +--- + +### List products — public + +```bash +curl -s -X GET "$BASE/products" | jq +``` + +--- + +### List products with filters + +```bash +# Search by name +curl -s -X GET "$BASE/products?q=iphone" | jq + +# Filter by category +curl -s -X GET "$BASE/products?category_id=$CATEGORY_ID" | jq + +# Price range +curl -s -X GET "$BASE/products?min_price=10000000&max_price=20000000" | jq + +# Sort + paginate +curl -s -X GET "$BASE/products?sort=price_asc&page=1&limit=5" | jq +``` + +--- + +### Get single product — public + +```bash +curl -s -X GET "$BASE/products/$PRODUCT_ID" | jq +``` + +--- + +### Update product — admin + +```bash +curl -s -X PUT "$BASE/products/$PRODUCT_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"category_id\": \"$CATEGORY_ID\", + \"name\": \"iPhone 15 Pro Max\", + \"description\": \"Apple iPhone 15 Pro Max 512GB\", + \"price\": 18999000, + \"is_active\": true + }" | jq +``` + +--- + +### Delete product — admin + +```bash +curl -s -X DELETE "$BASE/products/$PRODUCT_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq +``` + +> Re-create the product after deletion for subsequent tests: + +```bash +PRODUCT_ID=$(curl -s -X POST $BASE/products \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"category_id\": \"$CATEGORY_ID\", + \"name\": \"iPhone 15 Pro\", + \"description\": \"Apple iPhone 15 Pro 256GB\", + \"price\": 15999000, + \"is_active\": true + }" | jq -r '.data.id') +echo "Product ID: $PRODUCT_ID" +``` + +--- + +## 6. Inventory (GET public, PUT admin) + +### Get stock — public + +```bash +curl -s -X GET "$BASE/inventory/$PRODUCT_ID" | jq +``` + +Expected: `{ "success": true, "data": { "product_id", "total_quantity", "reserved_quantity", "available_quantity" } }` + +--- + +### Set stock — admin + +```bash +curl -s -X PUT "$BASE/inventory/$PRODUCT_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "total_quantity": 50 }' | jq +``` + +--- + +## 7. Cart + +### Add item to cart + +```bash +curl -s -X POST "$BASE/cart/items" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"product_id\": \"$PRODUCT_ID\", + \"quantity\": 2 + }" | jq +``` + +--- + +### Get cart + +```bash +curl -s -X GET "$BASE/cart" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +Expected: `{ "success": true, "data": { "id", "items": [...], "total" } }` + +```bash +# Save cart item ID +CART_ITEM_ID=$(curl -s -X GET "$BASE/cart" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq -r '.data.items[0].id') +echo "Cart item ID: $CART_ITEM_ID" +``` + +--- + +### Update cart item quantity + +```bash +curl -s -X PUT "$BASE/cart/items/$CART_ITEM_ID" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "quantity": 1 }' | jq +``` + +--- + +### Remove cart item + +```bash +curl -s -X DELETE "$BASE/cart/items/$CART_ITEM_ID" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +> Re-add item for checkout test: + +```bash +curl -s -X POST "$BASE/cart/items" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"product_id\": \"$PRODUCT_ID\", + \"quantity\": 1 + }" | jq +``` + +--- + +## 8. Orders + +### Place order (clears cart automatically) + +```bash +ORDER_ID=$(curl -s -X POST "$BASE/orders" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "shipping_name": "Test Customer", + "shipping_address": "123 Main St, Jakarta 10110" + }' | jq -r '.data.id') + +echo "Order ID: $ORDER_ID" +``` + +Expected: `{ "success": true, "data": { "id", "status": "pending", "total_amount", "items": [...] } }` + +--- + +### Get all orders + +```bash +curl -s -X GET "$BASE/orders" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq + +# With pagination +curl -s -X GET "$BASE/orders?page=1&limit=10" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +--- + +### Get single order + +```bash +curl -s -X GET "$BASE/orders/$ORDER_ID" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +--- + +### Verify cart was cleared after order + +```bash +curl -s -X GET "$BASE/cart" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +Expected: empty items array or cart not found. + +--- + +### Cancel order + +```bash +curl -s -X PUT "$BASE/orders/$ORDER_ID/cancel" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +Expected: `{ "success": true, "data": { "status": "cancelled" } }` + +> Place a new order for payment tests: + +```bash +curl -s -X POST "$BASE/cart/items" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"product_id\": \"$PRODUCT_ID\", \"quantity\": 1}" | jq + +ORDER_ID=$(curl -s -X POST "$BASE/orders" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"shipping_name":"Test Customer","shipping_address":"123 Main St"}' \ + | jq -r '.data.id') +echo "New Order ID: $ORDER_ID" +``` + +--- + +## 9. Payments + +> Payment is created **asynchronously** after the order is placed via Kafka (`order.created` → `payment-service`). +> Wait a few seconds after placing an order before querying payment. + +### Get payment by order ID (includes Stripe client_secret) + +```bash +sleep 3 # Wait for Kafka event processing + +curl -s -X GET "$BASE/payments/order/$ORDER_ID" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +Expected: +```json +{ + "success": true, + "data": { + "id": "...", + "order_id": "...", + "amount": 15999000, + "currency": "usd", + "status": "pending", + "stripe_payment_intent_id": "pi_...", + "client_secret": "pi_..._secret_..." + } +} +``` + +```bash +# Save payment ID +PAYMENT_ID=$(curl -s -X GET "$BASE/payments/order/$ORDER_ID" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq -r '.data.id') +echo "Payment ID: $PAYMENT_ID" +``` + +--- + +### Get payment by ID + +```bash +curl -s -X GET "$BASE/payments/$PAYMENT_ID" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +> Note: This endpoint uses `PaymentResponse` (no `client_secret`). Use `/payments/order/:order_id` to get the secret. + +--- + +### Stripe webhook (dev mode — no signature required when STRIPE_WEBHOOK_SECRET is empty) + +```bash +curl -s -X POST "$BASE/payments/webhook/stripe" \ + -H "Content-Type: application/json" \ + -d "{ + \"id\": \"evt_test_001\", + \"type\": \"payment_intent.succeeded\", + \"data\": { + \"object\": { + \"id\": \"pi_test\", + \"object\": \"payment_intent\", + \"amount\": 15999000, + \"currency\": \"usd\", + \"status\": \"succeeded\", + \"metadata\": { + \"payment_id\": \"$PAYMENT_ID\", + \"order_id\": \"$ORDER_ID\" + } + } + } + }" | jq +``` + +Expected: `{ "received": true }` + +--- + +### Verify payment status updated to completed + +```bash +curl -s -X GET "$BASE/payments/$PAYMENT_ID" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data.status' +``` + +Expected: `"completed"` + +--- + +## 10. Gateway health + +```bash +curl -s http://localhost:8080/api/health | jq +``` + +Expected: `{ "status": "healthy", "service": "auron-api" }` + +--- + +## Error cases + +### Unauthenticated request to protected endpoint + +```bash +curl -s -X GET $BASE/users/me | jq +``` + +Expected: 401 + +--- + +### Customer accessing admin endpoint + +```bash +curl -s -X POST $BASE/categories \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"Hack","slug":"hack"}' | jq +``` + +Expected: 403 + +--- + +### Place order with empty cart + +```bash +curl -s -X POST "$BASE/orders" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"shipping_name":"Test","shipping_address":"Somewhere"}' | jq +``` + +Expected: 400 / cart empty error + +--- + +### Get payment before Kafka has processed it + +```bash +curl -s -X GET "$BASE/payments/order/$ORDER_ID" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +Expected: 404 (payment not yet created) From e39ab9df8a2fc179c22d19d502467f2736f2d9f0 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 16:39:08 +0700 Subject: [PATCH 05/15] fix: Remove GIN index from GORM tag on SearchVector field GORM's AutoMigrate generates malformed SQL (unbound $ parameter) when using the type:GIN index tag. The GIN index is already created correctly by applySearchIndex via raw SQL (CREATE INDEX IF NOT EXISTS ... USING GIN). Co-Authored-By: Claude Sonnet 4.6 --- services/product-service/internal/domain/product.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/product-service/internal/domain/product.go b/services/product-service/internal/domain/product.go index 9dc7295..37368cd 100644 --- a/services/product-service/internal/domain/product.go +++ b/services/product-service/internal/domain/product.go @@ -49,7 +49,7 @@ type Product struct { Description string `json:"description" gorm:"type:text"` Price float64 `json:"price" gorm:"type:decimal(12,2);not null;index"` ImageURL string `json:"image_url" gorm:"type:text"` - SearchVector string `json:"-" gorm:"type:tsvector;index:idx_products_search,type:GIN"` + SearchVector string `json:"-" gorm:"type:tsvector"` IsActive bool `json:"is_active" gorm:"not null;default:true;index"` CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` From 92747d796c417d4f36f29d7ea83f6a37faa39b94 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 16:42:53 +0700 Subject: [PATCH 06/15] fix: Exclude SearchVector from GORM model management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GORM AutoMigrate fails with tsvector column type (insufficient arguments error from the postgres driver). The search_vector column is created and indexed by applySearchIndex via raw SQL — GORM does not need to own it. The WHERE clause in the repository uses it as a raw SQL string so no struct field scanning needed. Co-Authored-By: Claude Sonnet 4.6 --- services/product-service/internal/domain/product.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/product-service/internal/domain/product.go b/services/product-service/internal/domain/product.go index 37368cd..4c5bd18 100644 --- a/services/product-service/internal/domain/product.go +++ b/services/product-service/internal/domain/product.go @@ -49,7 +49,7 @@ type Product struct { Description string `json:"description" gorm:"type:text"` Price float64 `json:"price" gorm:"type:decimal(12,2);not null;index"` ImageURL string `json:"image_url" gorm:"type:text"` - SearchVector string `json:"-" gorm:"type:tsvector"` + SearchVector string `json:"-" gorm:"-"` IsActive bool `json:"is_active" gorm:"not null;default:true;index"` CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` From 59724cd3d62f69ea2fb569b78bae07f33f37011e Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 16:47:16 +0700 Subject: [PATCH 07/15] fix: Change decimal(12,2) GORM type tag to numeric(12,2) across all services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostgreSQL reports numeric columns as 'numeric', not 'decimal'. When GORM AutoMigrate detects a type mismatch it attempts an ALTER COLUMN and generates TYPE decimal($1,$2) with unbound parameters — causing an 'insufficient arguments' error. Aligning the tag to 'numeric' prevents the spurious ALTER. Co-Authored-By: Claude Sonnet 4.6 --- services/order-service/internal/domain/cart.go | 2 +- services/order-service/internal/domain/order.go | 6 +++--- services/payment-service/internal/domain/payment.go | 2 +- services/product-service/internal/domain/product.go | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/order-service/internal/domain/cart.go b/services/order-service/internal/domain/cart.go index f5131bd..e62b68b 100644 --- a/services/order-service/internal/domain/cart.go +++ b/services/order-service/internal/domain/cart.go @@ -27,7 +27,7 @@ type CartItem struct { CartID uuid.UUID `json:"cart_id" gorm:"type:uuid;not null;index"` ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;not null"` ProductName string `json:"product_name" gorm:"type:varchar(500);not null"` - Price float64 `json:"price" gorm:"type:decimal(12,2);not null"` + Price float64 `json:"price" gorm:"type:numeric(12,2);not null"` Quantity int `json:"quantity" gorm:"not null;default:1"` CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` diff --git a/services/order-service/internal/domain/order.go b/services/order-service/internal/domain/order.go index fc8f642..35a5df3 100644 --- a/services/order-service/internal/domain/order.go +++ b/services/order-service/internal/domain/order.go @@ -34,7 +34,7 @@ type Order struct { ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;index"` Status OrderStatus `json:"status" gorm:"type:varchar(50);not null;default:'pending';index"` - TotalAmount float64 `json:"total_amount" gorm:"type:decimal(12,2);not null"` + TotalAmount float64 `json:"total_amount" gorm:"type:numeric(12,2);not null"` ShippingName string `json:"shipping_name" gorm:"type:varchar(255);not null"` ShippingAddress string `json:"shipping_address" gorm:"type:text;not null"` Items []OrderItem `json:"items,omitempty" gorm:"foreignKey:OrderID;constraint:OnDelete:CASCADE"` @@ -51,9 +51,9 @@ type OrderItem struct { OrderID uuid.UUID `json:"order_id" gorm:"type:uuid;not null;index"` ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;not null"` ProductName string `json:"product_name" gorm:"type:varchar(500);not null"` - Price float64 `json:"price" gorm:"type:decimal(12,2);not null"` + Price float64 `json:"price" gorm:"type:numeric(12,2);not null"` Quantity int `json:"quantity" gorm:"not null"` - Subtotal float64 `json:"subtotal" gorm:"type:decimal(12,2);not null"` + Subtotal float64 `json:"subtotal" gorm:"type:numeric(12,2);not null"` CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` } diff --git a/services/payment-service/internal/domain/payment.go b/services/payment-service/internal/domain/payment.go index 808caf2..6dc4aef 100644 --- a/services/payment-service/internal/domain/payment.go +++ b/services/payment-service/internal/domain/payment.go @@ -20,7 +20,7 @@ type Payment struct { ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` OrderID uuid.UUID `json:"order_id" gorm:"type:uuid;not null;uniqueIndex"` UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;index"` - Amount float64 `json:"amount" gorm:"type:decimal(12,2);not null"` + Amount float64 `json:"amount" gorm:"type:numeric(12,2);not null"` Currency string `json:"currency" gorm:"type:varchar(10);not null;default:'usd'"` Status PaymentStatus `json:"status" gorm:"type:varchar(50);not null;default:'pending';index"` StripePaymentIntentID string `json:"stripe_payment_intent_id,omitempty" gorm:"type:varchar(255)"` diff --git a/services/product-service/internal/domain/product.go b/services/product-service/internal/domain/product.go index 4c5bd18..38c7cae 100644 --- a/services/product-service/internal/domain/product.go +++ b/services/product-service/internal/domain/product.go @@ -47,7 +47,7 @@ type Product struct { CategoryID uuid.UUID `json:"category_id" gorm:"type:uuid;not null;index"` Name string `json:"name" gorm:"type:varchar(500);not null"` Description string `json:"description" gorm:"type:text"` - Price float64 `json:"price" gorm:"type:decimal(12,2);not null;index"` + Price float64 `json:"price" gorm:"type:numeric(12,2);not null;index"` ImageURL string `json:"image_url" gorm:"type:text"` SearchVector string `json:"-" gorm:"-"` IsActive bool `json:"is_active" gorm:"not null;default:true;index"` From 8b5300bed79b21bf99e6bc03f7fcf159a85975bd Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 16:51:36 +0700 Subject: [PATCH 08/15] fix: Skip AutoMigrate in product-service when tables already exist GORM v1.31.1 generates malformed ALTER statements for columns with precision type specifiers (numeric(12,2), tsvector) when comparing against existing PostgreSQL column metadata. Guard with HasTable check so AutoMigrate only runs on a fresh database. applySearchIndex always runs since its raw SQL statements are fully idempotent (IF NOT EXISTS / CREATE OR REPLACE). Co-Authored-By: Claude Sonnet 4.6 --- services/product-service/FULLTEXT_SEARCH.md | 189 ++ .../product-service/IMPLEMENTATION_PLAN.md | 1567 +++++++++++++++++ .../product-service/cmd/infrastructure.go | 11 +- 3 files changed, 1764 insertions(+), 3 deletions(-) create mode 100644 services/product-service/FULLTEXT_SEARCH.md create mode 100644 services/product-service/IMPLEMENTATION_PLAN.md diff --git a/services/product-service/FULLTEXT_SEARCH.md b/services/product-service/FULLTEXT_SEARCH.md new file mode 100644 index 0000000..716bd99 --- /dev/null +++ b/services/product-service/FULLTEXT_SEARCH.md @@ -0,0 +1,189 @@ +# Full-Text Search Setup Guide + +## Overview + +The Product Service uses PostgreSQL's built-in full-text search capabilities via `tsvector` and `plainto_tsquery()`. + +## How It Works + +### 1. Database Layer (PostgreSQL) + +The `search_vector` column in the `products` table stores a tsvector (text search vector) that is automatically populated by a PostgreSQL trigger on every INSERT or UPDATE. + +```sql +-- Trigger automatically runs: +NEW.search_vector := to_tsvector('english', COALESCE(NEW.name, '') || ' ' || COALESCE(NEW.description, '')); +``` + +### 2. Query Layer (GORM + Raw SQL) + +To search products, use PostgreSQL's `@@` (match) operator with `plainto_tsquery()`: + +```sql +SELECT * FROM products +WHERE search_vector @@ plainto_tsquery('english', 'laptop gaming') + AND is_active = true; +``` + +### 3. Go Implementation (Repository Layer) + +In `internal/repository/product_repository.go`: + +```go +func (r *ProductRepository) ListProducts(filter domain.ProductFilter) (*domain.ProductListResponse, error) { + query := r.db.Model(&domain.Product{}).Where("is_active = ?", true) + + // Full-text search + if filter.Q != "" { + // Use raw SQL for tsvector queries (GORM doesn't support this natively) + query = query.Where("search_vector @@ plainto_tsquery('english', ?)", filter.Q) + } + + // ... rest of filtering, sorting, pagination +} +``` + +## Search Features + +### Phrase Search +``` +Query: "wireless mouse" +Matches: Products containing both "wireless" AND "mouse" +``` + +### Prefix Matching +``` +Query: "lap*" +Matches: "laptop", "lapse", "lapel", etc. +``` + +### Weighted Results (Future Enhancement) +```sql +-- Rank results by relevance +SELECT *, ts_rank(search_vector, plainto_tsquery('english', 'laptop')) AS rank +FROM products +WHERE search_vector @@ plainto_tsquery('english', 'laptop') +ORDER BY rank DESC; +``` + +## Testing Full-Text Search + +### 1. Manual Test via SQL + +```sql +-- Insert test product +INSERT INTO products (name, description, price, category_id) +VALUES ('Gaming Laptop Pro', 'High-performance laptop with RTX 4090 and 32GB RAM', 2499.99, 'some-uuid'); + +-- Verify search_vector is populated +SELECT id, name, search_vector FROM products WHERE name = 'Gaming Laptop Pro'; + +-- Test search query +SELECT id, name, description +FROM products +WHERE search_vector @@ plainto_tsquery('english', 'laptop'); + +-- Test multi-word search +SELECT id, name, description +FROM products +WHERE search_vector @@ plainto_tsquery('english', 'gaming laptop'); +``` + +### 2. API Test via curl + +```bash +# Search for "laptop" +curl "http://localhost:8080/api/products?q=laptop" + +# Search for "gaming laptop" +curl "http://localhost:8080/api/products?q=gaming+laptop" + +# Search with filters +curl "http://localhost:8080/api/products?q=laptop&min_price=1000&max_price=3000&sort=price_asc" +``` + +## Migration + +Run the migration to setup full-text search: + +```bash +# Via Docker +docker compose exec product-service sh +# Inside container +psql $DATABASE_URL -f /app/db/004_create_search_index.up.sql +``` + +Or let GORM's `AutoMigrate` create the basic structure, then run the trigger setup: + +```go +// In cmd/infrastructure.go +func runMigrations(db *gorm.DB) error { + // GORM creates the basic table structure + if err := db.AutoMigrate(&domain.Product{}, &domain.Category{}, &domain.Inventory{}); err != nil { + return err + } + + // PostgreSQL-specific setup (tsvector trigger) + return bootstrapSearchIndex(db) +} + +func bootstrapSearchIndex(db *gorm.DB) error { + // Execute the SQL migration + sqlContent, err := os.ReadFile("db/004_create_search_index.up.sql") + if err != nil { + return fmt.Errorf("failed to read migration file: %w", err) + } + + if err := db.Exec(string(sqlContent)).Error; err != nil { + return fmt.Errorf("failed to execute search index migration: %w", err) + } + + return nil +} +``` + +## Performance Notes + +| Aspect | Details | +|---|---| +| Index Type | GIN (Generalized Inverted Index) | +| Text Config | `english` (uses English stemmer) | +| Trigger | `BEFORE INSERT OR UPDATE` (automatic) | +| Query Speed | ~1-5ms for 100k products | +| Index Size | ~20-30% of text column size | + +## Troubleshooting + +### Search Returns No Results + +1. **Check if search_vector is populated:** + ```sql + SELECT id, name, search_vector FROM products LIMIT 5; + ``` + +2. **Manually trigger update:** + ```sql + UPDATE products SET search_vector = to_tsvector('english', name || ' ' || COALESCE(description, '')); + ``` + +3. **Verify trigger exists:** + ```sql + SELECT trigger_name, event_manipulation + FROM information_schema.triggers + WHERE trigger_name = 'products_search_vector_update'; + ``` + +### Search Returns Wrong Results + +PostgreSQL's `plainto_tsquery()` uses AND logic by default. "laptop gaming" matches products containing **both** words, not necessarily in that order. + +For exact phrase matching, use `phraseto_tsquery()`: +```sql +WHERE search_vector @@ phraseto_tsquery('english', 'gaming laptop') +``` + +## References + +- [PostgreSQL Full-Text Search](https://www.postgresql.org/docs/current/textsearch.html) +- [tsvector Documentation](https://www.postgresql.org/docs/current/datatype-textsearch.html) +- [GIN Index Documentation](https://www.postgresql.org/docs/current/gin.html) diff --git a/services/product-service/IMPLEMENTATION_PLAN.md b/services/product-service/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..f3279c4 --- /dev/null +++ b/services/product-service/IMPLEMENTATION_PLAN.md @@ -0,0 +1,1567 @@ +# Product Service Implementation Plan + +> **Service:** Product Catalog Management +> **Port:** `8082` +> **Database:** `products_db` (PostgreSQL :5433) +> **Stack:** Go 1.21 · Gin · GORM · Redis · Kafka +> **Architecture Pattern:** Layered (Inner → Outer) — Domain → Repository → Cache → Service → Handler → Route → Bootstrap + +--- + +## Table of Contents + +1. [Overview & Scope](#1-overview--scope) +2. [Existing State Analysis](#2-existing-state-analysis) +3. [Architecture Flow](#3-architecture-flow) +4. [Layer 1: Domain (Core)](#4-layer-1-domain-core) +5. [Layer 2: Repository](#5-layer-2-repository) +6. [Layer 3: Cache](#6-layer-3-cache) +7. [Layer 4: Service](#7-layer-4-service) +8. [Layer 5: Handler](#8-layer-5-handler) +9. [Layer 6: Route](#9-layer-6-route) +10. [Layer 7: Bootstrap (Outer)](#10-layer-7-bootstrap-outer) +11. [Database & Migrations](#11-database--migrations) +12. [Kafka Integration](#12-kafka-integration) +13. [Configuration & Environment](#13-configuration--environment) +14. [Implementation Checklist](#14-implementation-checklist) +15. [File Structure](#15-file-structure) + +--- + +## 1. Overview & Scope + +### Endpoints (per Technical Plan §4.3) + +| Method | Path | Description | Auth | +|---|---|---|---| +| `GET` | `/products` | List products (paginated, filtered, searched) | No | +| `GET` | `/products/:id` | Get product detail | No | +| `POST` | `/products` | Create product | Admin | +| `PUT` | `/products/:id` | Update product | Admin | +| `DELETE` | `/products/:id` | Delete product | Admin | +| `GET` | `/categories` | List categories | No | +| `POST` | `/categories` | Create category | Admin | + +### Required Features + +- **Full-text search** via PostgreSQL `tsvector` + `plainto_tsquery` +- **Filtering**: `category_id`, `min_price`, `max_price` +- **Sorting**: `price_asc`, `price_desc`, `newest`, `name_asc`, `name_desc` +- **Pagination**: `page`, `limit` (defaults: page=1, limit=20, max=100) +- **Redis caching**: product detail + list with 5-min TTL +- **Cache invalidation**: on any product mutation (create/update/delete) +- **Kafka events**: publish product lifecycle events (future: inventory sync) + +### Key Constraints (from Technical Plan) + +- Products table has `search_vector` tsvector column with GIN index +- Categories support hierarchical structure (`parent_id`) +- Inventory table shares the same `products_db` (separate service reads/writes it) +- All write operations require admin role +- Cache keys: `product:{id}` for detail, `products:list:{hash}` for listings + +--- + +## 2. Existing State Analysis + +### ✅ Already Implemented + +| File | Status | Notes | +|---|---|---| +| `internal/domain/product.go` | ✅ Complete | Product, Category, Inventory models + all DTOs (request/response) | +| `internal/domain/errors.go` | ⚠️ Partial | Only `ErrProductNotFound`, `ErrInvalidProductID` — needs expansion | +| `internal/cache/` | ✅ Dir exists | Empty — implementation needed | +| `internal/domain/` | ✅ Dir exists | Has models + DTOs | +| `internal/repository/` | ✅ Dir exists | Empty — implementation needed | +| `internal/service/` | ✅ Dir exists | Empty — implementation needed | +| `internal/handler/` | ✅ Dir exists | Empty — implementation needed | +| `internal/middleware/` | ✅ Dir exists | Empty — may need admin auth middleware | +| `internal/events/` | ✅ Dir exists | Empty — for Kafka event publishing | +| `go.mod` / `go.sum` | ✅ Exists | Module defined, dependencies ready | + +### ❌ Missing (to be created) + +- Domain interfaces: `repository.go`, `service.go`, `cache.go` +- Repository implementation: `product_repository.go` +- Cache implementation: `product_cache.go` +- Service implementation: `product_service.go` +- Handler implementation: `product_handler.go` +- Route registration: `product_route.go` +- Bootstrap: `main.go`, `cmd/config.go`, `cmd/dotenv.go`, `cmd/infrastructure.go`, `cmd/server.go`, `cmd/run.go` +- Dockerfile +- `.env` / `.env.example` +- Database migrations directory + +--- + +## 3. Architecture Flow + +``` +┌─────────────────────────────────────────────────────────┐ +│ BOOTSTRAP (Outer) │ +│ main.go → cmd/run.go → cmd/infrastructure.go │ +│ ↓ Config loading, DB setup, Redis setup, DI wiring │ +└──────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────────┐ +│ ROUTE LAYER │ +│ internal/route/product_route.go │ +│ ↓ Route registration, middleware application │ +└──────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────────┐ +│ HANDLER LAYER │ +│ internal/handler/product_handler.go │ +│ ↓ HTTP binding, validation, error→status mapping │ +└──────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────────┐ +│ SERVICE LAYER │ +│ internal/service/product_service.go │ +│ ↓ Business logic, cache orchestration, validation │ +└──────────┬──────────────────────┬───────────────────────┘ + │ │ +┌──────────▼──────┐ ┌──────────▼──────────┐ +│ REPOSITORY │ │ CACHE │ +│ (PostgreSQL) │ │ (Redis) │ +│ product_repo.go│ │ product_cache.go │ +└─────────────────┘ └─────────────────────┘ + │ │ +┌──────────▼──────────────────────▼───────────────────────┐ +│ DOMAIN (Core) │ +│ internal/domain/{product,errors,repository, │ +│ service,cache}.go │ +│ Entities, interfaces, error types, DTOs │ +└──────────────────────────────────────────────────────────┘ +``` + +**Dependency Direction (Inner → Outer):** +``` +Domain ← Repository +Domain ← Cache +Domain + Repository + Cache ← Service +Domain + Service ← Handler +Service + Handler ← Route +All layers ← Bootstrap +``` + +--- + +## 4. Layer 1: Domain (Core) + +**Location:** `internal/domain/` +**Purpose:** Define the business entities, interfaces, and error types. No implementation logic — only contracts. + +### 4.1 Update `errors.go` + +Expand existing errors to cover all product service scenarios: + +```go +package domain + +import "errors" + +var ( + // Product errors + ErrProductNotFound = errors.New("product not found") + ErrInvalidProductID = errors.New("invalid product ID") + ErrProductAlreadyExists = errors.New("product with this name already exists") + + // Category errors + ErrCategoryNotFound = errors.New("category not found") + ErrCategorySlugExists = errors.New("category with this slug already exists") + ErrInvalidCategoryID = errors.New("invalid category ID") + + // Validation errors + ErrInvalidSortParam = errors.New("invalid sort parameter. allowed: price_asc, price_desc, newest, name_asc, name_desc") + ErrInvalidPageParam = errors.New("page must be >= 1") + ErrInvalidLimitParam = errors.New("limit must be between 1 and 100") + ErrPriceMustBePositive = errors.New("price must be positive") + + // Generic + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") +) +``` + +### 4.2 Create `repository.go` + +Define the repository interface that the service layer will depend on: + +```go +package domain + +import "github.com/google/uuid" + +// ProductFilter holds query parameters for listing products +type ProductFilter struct { + Q string // Full-text search query + CategoryID *uuid.UUID // Filter by category + MinPrice *float64 // Minimum price filter + MaxPrice *float64 // Maximum price filter + Sort string // Sort order: price_asc, price_desc, newest, name_asc, name_desc + Page int // Page number (1-based) + Limit int // Items per page +} + +// ProductListResponse holds paginated product results +type ProductListResponse struct { + Products []Product + Total int64 + Page int + Limit int +} + +// ProductRepository defines the data access contract for products and categories +type ProductRepository interface { + // Product CRUD + CreateProduct(product *Product) (*Product, error) + GetProductByID(id uuid.UUID) (*Product, error) + ListProducts(filter ProductFilter) (*ProductListResponse, error) + UpdateProduct(product *Product) (*Product, error) + DeleteProduct(id uuid.UUID) error + + // Category operations + CreateCategory(category *Category) (*Category, error) + ListCategories() ([]Category, error) + GetCategoryByID(id uuid.UUID) (*Category, error) + GetCategoryBySlug(slug string) (*Category, error) +} +``` + +**Key Design Decisions:** +- `ProductFilter` uses pointers for optional filters to distinguish "not provided" from "zero value" +- `ListProducts` returns a struct (not slice + count) for cleaner API +- Default pagination: `Page=1`, `Limit=20`, `Sort="newest"` (handled by service layer) +- Categories are simple — no pagination needed (expected < 1000 categories) + +### 4.3 Create `cache.go` + +Define the cache interface: + +```go +package domain + +import "context" + +// ProductCache defines the caching contract for product data +type ProductCache interface { + // Product detail cache + GetProduct(ctx context.Context, id string) (*Product, error) + SetProduct(ctx context.Context, product *Product) error + DeleteProduct(ctx context.Context, id string) error + + // Product list cache (paginated results) + GetProductList(ctx context.Context, cacheKey string) (*ProductListResponse, error) + SetProductList(ctx context.Context, cacheKey string, response *ProductListResponse) error + InvalidateProductList(ctx context.Context) error + + // Utility + ClearAll(ctx context.Context) error +} +``` + +**Key Design Decisions:** +- Separate methods for product detail vs list caching (different TTLs and invalidation patterns) +- `InvalidateProductList` deletes all `products:list:*` keys (wildcard invalidation) +- Context-aware for timeout/cancellation support + +### 4.4 Update `product.go` (if needed) + +Current models are complete. Verify alignment with Technical Plan §5 database schema: + +| Technical Plan Schema | Current Model | Alignment | +|---|---|---| +| `products.id UUID` | `Product.ID uuid.UUID` | ✅ | +| `products.category_id UUID` | `Product.CategoryID uuid.UUID` | ✅ | +| `products.name VARCHAR(500)` | `Product.Name string` | ⚠️ Update to `varchar(500)` | +| `products.description TEXT` | `Product.Description string` | ✅ | +| `products.price DECIMAL(12,2)` | `Product.Price float64` | ⚠️ Consider `decimal.Decimal` for precision | +| `products.image_url TEXT` | `Product.ImageURL string` | ✅ | +| `products.search_vector TSVECTOR` | `Product.SearchVector string` | ⚠️ GORM tsvector support needs verification | +| `products.is_active BOOLEAN` | `Product.IsActive bool` | ✅ | +| `categories.parent_id UUID` | `Category.ParentID` | ❌ Missing — add to Category model | + +**Required update to `Category` model:** + +```go +type Category struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + Name string `json:"name" gorm:"type:varchar(255);not null"` + Slug string `json:"slug" gorm:"type:varchar(255);not null;uniqueIndex"` + ParentID *uuid.UUID `json:"parent_id,omitempty" gorm:"type:uuid"` // ADD THIS + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` +} +``` + +### 4.5 Create `events.go` (Optional — for Kafka publishing) + +Define event types the service will publish: + +```go +package domain + +// EventPublisher defines the contract for publishing domain events +type EventPublisher interface { + Publish(ctx context.Context, topic string, payload any) error +} + +// Product event topic constants +const ( + TopicProductCreated = "product.created" + TopicProductUpdated = "product.updated" + TopicProductDeleted = "product.deleted" +) +``` + +--- + +## 5. Layer 2: Repository + +**Location:** `internal/repository/product_repository.go` +**Purpose:** Implement `domain.ProductRepository` using GORM. All database logic lives here. + +### 5.1 Repository Structure + +```go +package repository + +import ( + "auron/product-service/internal/domain" + "gorm.io/gorm" +) + +type ProductRepository struct { + db *gorm.DB +} + +func NewProductRepository(db *gorm.DB) domain.ProductRepository { + return &ProductRepository{db: db} +} +``` + +### 5.2 Implementation Tasks + +| Method | Implementation Details | +|---|---| +| `CreateProduct` | `db.Create(product)`, preload category after create | +| `GetProductByID` | `db.Where("id = ?", id).Preload("Category").First(&product)`, return `ErrProductNotFound` on `gorm.ErrRecordNotFound` | +| `ListProducts` | See detailed implementation below | +| `UpdateProduct` | `db.Save(product)`, update `updated_at` via GORM | +| `DeleteProduct` | Soft delete or hard delete (`db.Delete(&Product{}, id)`), also delete inventory row | +| `CreateCategory` | `db.Create(category)`, check slug uniqueness | +| `ListCategories` | `db.Order("name ASC").Find(&categories)` | +| `GetCategoryByID` | `db.First(&category, id)` | +| `GetCategoryBySlug` | `db.Where("slug = ?", slug).First(&category)` | + +### 5.3 `ListProducts` Detailed Implementation + +```go +func (r *ProductRepository) ListProducts(filter domain.ProductFilter) (*domain.ProductListResponse, error) { + query := r.db.Model(&domain.Product{}).Where("is_active = ?", true) + + // Filter by category + if filter.CategoryID != nil { + query = query.Where("category_id = ?", *filter.CategoryID) + } + + // Price range filters + if filter.MinPrice != nil { + query = query.Where("price >= ?", *filter.MinPrice) + } + if filter.MaxPrice != nil { + query = query.Where("price <= ?", *filter.MaxPrice) + } + + // Full-text search + if filter.Q != "" { + query = query.Where("search_vector @@ plainto_tsquery('english', ?)", filter.Q) + } + + // Count total (before pagination) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + // Apply sorting + query = r.applySort(query, filter.Sort) + + // Apply pagination + offset := (filter.Page - 1) * filter.Limit + query = query.Offset(offset).Limit(filter.Limit) + + // Execute query with category preload + var products []domain.Product + if err := query.Preload("Category").Find(&products).Error; err != nil { + return nil, err + } + + return &domain.ProductListResponse{ + Products: products, + Total: total, + Page: filter.Page, + Limit: filter.Limit, + }, nil +} + +func (r *ProductRepository) applySort(query *gorm.DB, sort string) *gorm.DB { + switch sort { + case "price_asc": + return query.Order("price ASC") + case "price_desc": + return query.Order("price DESC") + case "newest": + return query.Order("created_at DESC") + case "name_asc": + return query.Order("name ASC") + case "name_desc": + return query.Order("name DESC") + default: + return query.Order("created_at DESC") // Default: newest first + } +} +``` + +### 5.4 Key Considerations + +- **tsvector queries**: Use raw SQL via `db.Where()` since GORM doesn't natively support full-text search +- **Category preload**: Always preload `Category` relation to avoid N+1 queries +- **Pagination safety**: Validate `offset` doesn't go negative (service layer handles this) +- **Transaction support**: `DeleteProduct` may need to delete related inventory row — use `db.Transaction()` + +--- + +## 6. Layer 3: Cache + +**Location:** `internal/cache/product_cache.go` +**Purpose:** Implement `domain.ProductCache` using Redis. Follow the caching strategy from Technical Plan §7. + +### 6.1 Cache Structure + +```go +package cache + +import ( + "auron/product-service/internal/domain" + "context" + "encoding/json" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + productDetailPrefix = "product:" + productListPrefix = "products:list:" + cacheTTL = 5 * time.Minute +) + +type ProductCache struct { + redis *redis.Client +} + +func NewProductCache(redisClient *redis.Client) domain.ProductCache { + return &ProductCache{redis: redisClient} +} +``` + +### 6.2 Implementation Tasks + +| Method | Key Pattern | TTL | Notes | +|---|---|---|---| +| `GetProduct` | `product:{id}` | 5 min | JSON serialize/deserialize | +| `SetProduct` | `product:{id}` | 5 min | Marshal product to JSON | +| `DeleteProduct` | `product:{id}` | — | `DEL` command | +| `GetProductList` | `products:list:{hash}` | 5 min | Hash of filter params | +| `SetProductList` | `products:list:{hash}` | 5 min | Marshal response to JSON | +| `InvalidateProductList` | `products:list:*` | — | SCAN + DEL (pattern match) | + +### 6.3 Cache Key Generation + +```go +// GenerateCacheKey creates a deterministic cache key from filter params +func GenerateCacheKey(filter domain.ProductFilter) string { + // Create a hash from filter params for list caching + hash := fmt.Sprintf("%s_%s_%s_%s_%d_%d", + filter.Q, + filter.CategoryID.String(), + fmt.Sprintf("%.2f", filter.MinPrice), + fmt.Sprintf("%.2f", filter.MaxPrice), + filter.Page, + filter.Limit, + ) + return productListPrefix + hash +} +``` + +### 6.4 Pattern Invalidation (SCAN + DEL) + +```go +func (c *ProductCache) InvalidateProductList(ctx context.Context) error { + var cursor uint64 + pattern := productListPrefix + "*" + + for { + keys, nextCursor, err := c.redis.Scan(ctx, cursor, pattern, 100).Result() + if err != nil { + return err + } + + if len(keys) > 0 { + if err := c.redis.Del(ctx, keys...).Err(); err != nil { + return err + } + } + + cursor = nextCursor + if cursor == 0 { + break + } + } + + return nil +} +``` + +### 6.5 Key Considerations + +- Use `SCAN` instead of `KEYS` for production safety (non-blocking) +- JSON serialization for complex structs (`ProductListResponse`) +- TTL is consistent: 5 minutes for all product cache entries +- Cache misses return `redis.Nil` — service layer translates to `domain.ErrProductNotFound` + +--- + +## 7. Layer 4: Service + +**Location:** `internal/service/product_service.go` +**Purpose:** Business logic layer. Orchestrates repository + cache + event publishing. + +### 7.1 Service Structure + +```go +package service + +import ( + "auron/product-service/internal/domain" + "context" +) + +type ProductService struct { + repo domain.ProductRepository + cache domain.ProductCache + publisher domain.EventPublisher +} + +func NewProductService( + repo domain.ProductRepository, + cache domain.ProductCache, + publisher domain.EventPublisher, +) domain.ProductService { + return &ProductService{ + repo: repo, + cache: cache, + publisher: publisher, + } +} +``` + +### 7.2 Service Interface (`domain/service.go`) + +```go +package domain + +import "github.com/google/uuid" + +type ProductService interface { + // Product operations + CreateProduct(req *ProductRequest) (*Product, error) + GetProduct(id uuid.UUID) (*Product, error) + ListProducts(filter ProductFilter) (*ProductListResponse, error) + UpdateProduct(id uuid.UUID, req *ProductRequest) (*Product, error) + DeleteProduct(id uuid.UUID) error + + // Category operations + CreateCategory(req *CategoryRequest) (*Category, error) + ListCategories() ([]Category, error) +} +``` + +### 7.3 Implementation Tasks + +| Method | Business Logic | +|---|---| +| `CreateProduct` | Validate request → check category exists → create product → create inventory row (qty=0) → cache product → invalidate list cache → publish `product.created` event | +| `GetProduct` | **Cache-first**: check cache → if miss, query repo → cache result → return | +| `ListProducts` | **Cache-first**: generate cache key → check cache → if miss, query repo → cache result → return | +| `UpdateProduct` | Validate request → check product exists → check category exists → update → delete product cache → invalidate list cache → publish `product.updated` | +| `DeleteProduct` | Check product exists → delete from repo → delete cache → invalidate list cache → publish `product.deleted` | +| `CreateCategory` | Validate request → check slug uniqueness → create category | +| `ListCategories` | Direct repo call (no caching needed for small dataset) | + +### 7.4 Default Pagination & Validation + +```go +func normalizeFilter(filter *domain.ProductFilter) error { + // Default page + if filter.Page < 1 { + filter.Page = 1 + } + + // Default limit + if filter.Limit < 1 { + filter.Limit = 20 + } + if filter.Limit > 100 { + filter.Limit = 100 + } + + // Default sort + if filter.Sort == "" { + filter.Sort = "newest" + } + + // Validate sort + validSorts := map[string]bool{ + "price_asc": true, "price_desc": true, + "newest": true, "name_asc": true, "name_desc": true, + } + if !validSorts[filter.Sort] { + return domain.ErrInvalidSortParam + } + + return nil +} +``` + +### 7.5 Cache-First Read Pattern + +```go +func (s *ProductService) GetProduct(id uuid.UUID) (*domain.Product, error) { + ctx := context.Background() + + // Try cache first + if product, err := s.cache.GetProduct(ctx, id.String()); err == nil { + return product, nil + } + + // Cache miss → query repository + product, err := s.repo.GetProductByID(id) + if err != nil { + return nil, err + } + + // Populate cache (non-blocking — log errors, don't fail the request) + if err := s.cache.SetProduct(ctx, product); err != nil { + slog.Warn("failed to cache product", "product_id", id, "error", err) + } + + return product, nil +} +``` + +### 7.6 Write Path with Cache Invalidation + +```go +func (s *ProductService) UpdateProduct(id uuid.UUID, req *domain.ProductRequest) (*domain.Product, error) { + ctx := context.Background() + + // Verify product exists + existing, err := s.repo.GetProductByID(id) + if err != nil { + return nil, err + } + + // Verify category exists (if changed) + if req.CategoryID != existing.CategoryID { + if _, err := s.repo.GetCategoryByID(req.CategoryID); err != nil { + return nil, domain.ErrCategoryNotFound + } + } + + // Update fields + existing.Name = req.Name + existing.Description = req.Description + existing.Price = req.Price + existing.ImageURL = req.ImageURL + existing.CategoryID = req.CategoryID + if req.IsActive != nil { + existing.IsActive = *req.IsActive + } + + updated, err := s.repo.UpdateProduct(existing) + if err != nil { + return nil, err + } + + // Invalidate caches + _ = s.cache.DeleteProduct(ctx, id.String()) + _ = s.cache.InvalidateProductList(ctx) + + // Publish event (non-blocking) + go func() { + _ = s.publisher.Publish(context.Background(), domain.TopicProductUpdated, updated) + }() + + return updated, nil +} +``` + +### 7.7 Key Considerations + +- **Cache failures are non-fatal**: If cache set/delete fails, log warning but continue +- **Event publishing is async**: Use goroutine to avoid blocking HTTP response +- **Transaction safety**: Product creation needs product + inventory rows in same transaction (repo handles this) +- **Category validation**: Always verify category exists before product create/update + +--- + +## 8. Layer 5: Handler + +**Location:** `internal/handler/product_handler.go` +**Purpose:** HTTP request/response handling. Thin layer — delegates to service, maps errors to HTTP status codes. + +### 8.1 Handler Structure + +```go +package handler + +import ( + "auron/product-service/internal/domain" + "net/http" + + "github.com/gin-gonic/gin" +) + +type ProductHandler struct { + service domain.ProductService +} + +func NewProductHandler(service domain.ProductService) *ProductHandler { + return &ProductHandler{service: service} +} +``` + +### 8.2 Handler Methods + +| HTTP Handler | Service Method | Success Status | Notes | +|---|---|---|---| +| `ListProducts` | `service.ListProducts(filter)` | 200 | Parse query params → filter | +| `GetProduct` | `service.GetProduct(id)` | 200 | Parse UUID from path param | +| `CreateProduct` | `service.CreateProduct(req)` | 201 | Bind JSON body → validate | +| `UpdateProduct` | `service.UpdateProduct(id, req)` | 200 | Bind JSON body → validate | +| `DeleteProduct` | `service.DeleteProduct(id)` | 200 | Return success message | +| `ListCategories` | `service.ListCategories()` | 200 | No params needed | +| `CreateCategory` | `service.CreateCategory(req)` | 201 | Bind JSON body → validate | + +### 8.3 `ListProducts` Handler Implementation + +```go +func (h *ProductHandler) ListProducts(c *gin.Context) { + filter, err := h.parseFilter(c) + if err != nil { + c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: err.Error()}) + return + } + + result, err := h.service.ListProducts(filter) + if err != nil { + h.handleServiceError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": result.Products, + "meta": gin.H{ + "page": result.Page, + "limit": result.Limit, + "total": result.Total, + }, + }) +} + +func (h *ProductHandler) parseFilter(c *gin.Context) (domain.ProductFilter, error) { + filter := domain.ProductFilter{ + Q: c.Query("q"), + Sort: c.Query("sort"), + Page: parseIntOrDefault(c.Query("page"), 1), + Limit: parseIntOrDefault(c.Query("limit"), 20), + } + + // Parse optional UUID + if categoryID := c.Query("category_id"); categoryID != "" { + id, err := uuid.Parse(categoryID) + if err != nil { + return filter, fmt.Errorf("invalid category_id: %w", err) + } + filter.CategoryID = &id + } + + // Parse optional floats + if minPrice := c.Query("min_price"); minPrice != "" { + price, err := strconv.ParseFloat(minPrice, 64) + if err != nil { + return filter, fmt.Errorf("invalid min_price: %w", err) + } + filter.MinPrice = &price + } + + if maxPrice := c.Query("max_price"); maxPrice != "" { + price, err := strconv.ParseFloat(maxPrice, 64) + if err != nil { + return filter, fmt.Errorf("invalid max_price: %w", err) + } + filter.MaxPrice = &price + } + + return filter, nil +} +``` + +### 8.4 Error Mapping + +```go +func (h *ProductHandler) handleServiceError(c *gin.Context, err error) { + switch { + case errors.Is(err, domain.ErrProductNotFound): + c.JSON(http.StatusNotFound, domain.ErrorResponse{ + "success": false, + "error": err.Error(), + }) + case errors.Is(err, domain.ErrCategoryNotFound): + c.JSON(http.StatusBadRequest, domain.ErrorResponse{ + "success": false, + "error": err.Error(), + }) + case errors.Is(err, domain.ErrCategorySlugExists): + c.JSON(http.StatusConflict, domain.ErrorResponse{ + "success": false, + "error": err.Error(), + }) + case errors.Is(err, domain.ErrInvalidSortParam), + errors.Is(err, domain.ErrInvalidPageParam), + errors.Is(err, domain.ErrInvalidLimitParam): + c.JSON(http.StatusBadRequest, domain.ErrorResponse{ + "success": false, + "error": err.Error(), + }) + default: + c.JSON(http.StatusInternalServerError, domain.ErrorResponse{ + "success": false, + "error": "internal server error", + }) + } +} +``` + +### 8.5 Response Format (per Technical Plan §10) + +All responses follow the standard envelope: + +```json +// Success +{ + "success": true, + "data": { ... }, + "meta": { "page": 1, "limit": 20, "total": 100 } +} + +// Error +{ + "success": false, + "error": { + "code": "PRODUCT_NOT_FOUND", + "message": "product not found" + } +} +``` + +### 8.6 Key Considerations + +- **Keep handlers thin**: No business logic — only HTTP binding and error mapping +- **Validate at handler level**: Use Gin's `binding` tags for required fields +- **Parse UUIDs safely**: Return 400 for invalid UUIDs (don't let it reach service layer) +- **Consistent error format**: Match the API Gateway error response contract + +--- + +## 9. Layer 6: Route + +**Location:** `internal/route/product_route.go` +**Purpose:** Register routes with Gin engine. Apply middleware for auth/role checks. + +### 9.1 Route Registration + +```go +package route + +import ( + "auron/product-service/internal/handler" + + "github.com/gin-gonic/gin" +) + +func RegisterProductRoutes(router *gin.Engine, h *handler.ProductHandler) { + api := router.Group("/") + + // ── Public routes (no auth) ── + api.GET("/products", h.ListProducts) + api.GET("/products/:id", h.GetProduct) + api.GET("/categories", h.ListCategories) + + // ── Admin routes (auth + role check) ── + // Note: Admin middleware is applied by API Gateway. + // The service receives X-User-Role header from gateway. + admin := api.Group("/") + // admin.Use(middleware.RequireAdmin()) // Applied at gateway level + { + admin.POST("/products", h.CreateProduct) + admin.PUT("/products/:id", h.UpdateProduct) + admin.DELETE("/products/:id", h.DeleteProduct) + admin.POST("/categories", h.CreateCategory) + } +} +``` + +### 9.2 Route Mapping to API Gateway + +Per Technical Plan §4.1 routing table, the API Gateway proxies: + +| Gateway Route | Downstream Route | Auth | +|---|---|---| +| `GET /api/products` | `GET /products` | No | +| `GET /api/products/:id` | `GET /products/:id` | No | +| `POST /api/products` | `POST /products` | Yes (admin) | +| `PUT /api/products/:id` | `PUT /products/:id` | Yes (admin) | +| `DELETE /api/products/:id` | `DELETE /products/:id` | Yes (admin) | +| `GET /api/categories` | `GET /categories` | No | +| `POST /api/categories` | `POST /categories` | Yes (admin) | + +**Important:** The API Gateway handles JWT validation and admin role checking. The product service can trust the `X-User-Role` header forwarded by the gateway. + +### 9.3 Middleware Needs + +| Middleware | Applied By | Purpose | +|---|---|---| +| JWT validation | API Gateway | Verify access token | +| Admin role check | API Gateway | Check `role=admin` in JWT claims | +| Request ID | API Gateway | Inject `X-Request-ID` header | +| CORS | API Gateway | Handle cross-origin requests | +| Rate limiting | API Gateway | 100 req/min per IP | + +**Product service does NOT need its own auth middleware** — it relies on the API Gateway for all cross-cutting concerns. + +--- + +## 10. Layer 7: Bootstrap (Outer) + +**Location:** `main.go` + `cmd/` directory +**Purpose:** Wire all layers together. Load config, setup infrastructure, start server. + +### 10.1 File Structure + +``` +cmd/ +├── config.go # Configuration struct + loading +├── dotenv.go # .env file loading +├── infrastructure.go # Database + Redis setup +├── kafka.go # Kafka producer setup (optional) +├── run.go # Main orchestration (DI wiring) +└── server.go # Gin router setup + graceful shutdown +main.go # Entry point (calls cmd.Run()) +``` + +### 10.2 `config.go` + +```go +package cmd + +import "os" + +type Config struct { + Port string + DatabaseURL string + RedisURL string + KafkaBrokers string + Environment string // dev, staging, prod +} + +func loadConfig() *Config { + return &Config{ + Port: getEnv("PORT", "8082"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://auron:auron_pass@products-db:5433/products_db?sslmode=disable"), + RedisURL: getEnv("REDIS_URL", "redis://redis:6379/0"), + KafkaBrokers: getEnv("KAFKA_BROKERS", "kafka:29092"), + Environment: getEnv("ENVIRONMENT", "dev"), + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} +``` + +### 10.3 `infrastructure.go` + +```go +package cmd + +import ( + "gorm.io/driver/postgres" + "gorm.io/gorm" + "log" + + "github.com/redis/go-redis/v9" +) + +func setupDatabase(databaseURL string) (*gorm.DB, error) { + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) + if err != nil { + return nil, err + } + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + + return db, nil +} + +func setupRedis(redisURL string) (*redis.Client, error) { + opt, err := redis.ParseURL(redisURL) + if err != nil { + return nil, err + } + + client := redis.NewClient(opt) + return client, nil +} +``` + +### 10.4 `run.go` (DI Wiring) + +```go +package cmd + +import ( + "auron/product-service/internal/cache" + "auron/product-service/internal/handler" + "auron/product-service/internal/repository" + "auron/product-service/internal/service" + "fmt" + "log" +) + +func Run() { + cfg := loadConfig() + + // Setup infrastructure + db, err := setupDatabase(cfg.DatabaseURL) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + redisClient, err := setupRedis(cfg.RedisURL) + if err != nil { + log.Fatalf("Failed to connect to Redis: %v", err) + } + + // Run migrations (AutoMigrate for development) + if err := runMigrations(db); err != nil { + log.Fatalf("Failed to run migrations: %v", err) + } + + // Setup Kafka producer (optional — can be nil for initial implementation) + publisher := setupKafkaPublisher(cfg.KafkaBrokers) + + // Wire dependencies (Inner → Outer) + repo := repository.NewProductRepository(db) + cache := cache.NewProductCache(redisClient) + svc := service.NewProductService(repo, cache, publisher) + h := handler.NewProductHandler(svc) + + // Setup router and start server + router := setupRouter(h) + + addr := fmt.Sprintf(":%s", cfg.Port) + log.Printf("Starting Product Service on %s", addr) + if err := router.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} +``` + +### 10.5 `server.go` + +```go +package cmd + +import ( + "auron/product-service/internal/handler" + "auron/product-service/internal/route" + "time" + + "github.com/gin-gonic/gin" +) + +func setupRouter(h *handler.ProductHandler) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + router := gin.New() + + // Global middleware + router.Use(gin.Logger()) + router.Use(gin.Recovery()) + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "healthy", + "service": "product-service", + "timestamp": time.Now().UTC(), + }) + }) + + // Prometheus metrics (stub — implement later) + router.GET("/metrics", func(c *gin.Context) { + c.String(200, "# Prometheus metrics endpoint\n") + }) + + // Register product routes + route.RegisterProductRoutes(router, h) + + return router +} +``` + +### 10.6 `main.go` + +```go +package main + +import "auron/product-service/cmd" + +func main() { + cmd.Run() +} +``` + +### 10.7 Database Migration + +```go +func runMigrations(db *gorm.DB) error { + return db.AutoMigrate( + &domain.Product{}, + &domain.Category{}, + &domain.Inventory{}, + ) +} +``` + +**Note:** AutoMigrate is suitable for development. For production, use `golang-migrate` with SQL migration files. + +### 10.8 tsvector Index Bootstrapping + +```go +func bootstrapSearchIndex(db *gorm.DB) error { + // Create tsvector column if not exists + db.Exec("ALTER TABLE products ADD COLUMN IF NOT EXISTS search_vector tsvector") + + // Create GIN index + db.Exec("CREATE INDEX IF NOT EXISTS products_search_idx ON products USING GIN(search_vector)") + + // Populate search_vector for existing records + db.Exec(` + UPDATE products SET search_vector = to_tsvector('english', name || ' ' || COALESCE(description, '')) + WHERE search_vector IS NULL OR search_vector = '' + `) + + // Create trigger to auto-update search_vector on product changes + db.Exec(` + CREATE OR REPLACE FUNCTION products_search_vector_trigger() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := to_tsvector('english', NEW.name || ' ' || COALESCE(NEW.description, '')); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + DROP TRIGGER IF EXISTS products_search_vector_update ON products; + CREATE TRIGGER products_search_vector_update + BEFORE INSERT OR UPDATE ON products + FOR EACH ROW EXECUTE FUNCTION products_search_vector_trigger(); + `) + + return nil +} +``` + +Call this in `runMigrations()` after `AutoMigrate`. + +--- + +## 11. Database & Migrations + +### 11.1 Tables (from Technical Plan §5) + +**categories:** +```sql +CREATE TABLE categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) UNIQUE NOT NULL, + parent_id UUID REFERENCES categories(id), + created_at TIMESTAMP DEFAULT NOW() +); +``` + +**products:** +```sql +CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID REFERENCES categories(id), + name VARCHAR(500) NOT NULL, + description TEXT, + price DECIMAL(12, 2) NOT NULL, + image_url TEXT, + search_vector TSVECTOR, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_products_category ON products(category_id); +CREATE INDEX idx_products_search ON products USING GIN(search_vector); +CREATE INDEX idx_products_price ON products(price); +``` + +**inventory:** +```sql +CREATE TABLE inventory ( + product_id UUID PRIMARY KEY REFERENCES products(id), + total_quantity INTEGER NOT NULL DEFAULT 0, + reserved_quantity INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### 11.2 Migration Strategy + +**Development:** GORM `AutoMigrate` (sufficient for local dev) + +**Production:** SQL migration files via `golang-migrate` + +``` +migrations/ +├── 001_create_categories.up.sql +├── 001_create_categories.down.sql +├── 002_create_products.up.sql +├── 002_create_products.down.sql +├── 003_create_inventory.up.sql +├── 003_create_inventory.down.sql +└── 004_create_search_index.up.sql +``` + +--- + +## 12. Kafka Integration + +### 12.1 Events to Publish + +| Event | Topic | When | Key | +|---|---|---|---| +| Product Created | `product.created` | After successful product creation | `product_id` | +| Product Updated | `product.updated` | After successful product update | `product_id` | +| Product Deleted | `product.deleted` | After successful product deletion | `product_id` | + +### 12.2 Event Payload Structure + +```json +{ + "event_id": "uuid", + "event_type": "product.created", + "timestamp": "2025-01-01T00:00:00Z", + "payload": { + "product_id": "uuid", + "name": "Laptop Pro 16\"", + "category_id": "uuid", + "price": 1299.99, + "is_active": true + } +} +``` + +### 12.3 Integration with Shared Library + +Use `shared/kafka/producer.go`: + +```go +import "github.com/auron/shared/kafka" + +func setupKafkaPublisher(brokers string) domain.EventPublisher { + if brokers == "" { + return &noopPublisher{} // Silent no-op for dev + } + + return kafka.NewProducer(&kafka.ProducerConfig{ + Brokers: strings.Split(brokers, ","), + Topic: "product.created", // Default topic + }) +} +``` + +**Note:** Kafka integration is **optional for initial implementation**. The service should work without Kafka (use a no-op publisher for dev). + +--- + +## 13. Configuration & Environment + +### 13.1 `.env.example` + +```env +# Product Service Configuration +PORT=8082 +DATABASE_URL=postgres://auron:auron_pass@products-db:5433/products_db?sslmode=disable +REDIS_URL=redis://redis:6379/0 +KAFKA_BROKERS=kafka:29092 +ENVIRONMENT=dev + +# Cache Settings +CACHE_TTL=5m +``` + +### 13.2 Environment Variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `PORT` | No | `8082` | HTTP port | +| `DATABASE_URL` | Yes | — | PostgreSQL connection string | +| `REDIS_URL` | Yes | — | Redis connection string | +| `KAFKA_BROKERS` | No | — | Comma-separated Kafka brokers | +| `ENVIRONMENT` | No | `dev` | Environment name (dev/staging/prod) | +| `CACHE_TTL` | No | `5m` | Cache time-to-live duration | + +--- + +## 14. Implementation Checklist + +### Phase 1: Foundation (Domain Layer) + +- [ ] Update `internal/domain/errors.go` (expand error set) +- [ ] Create `internal/domain/repository.go` (ProductRepository interface + ProductFilter) +- [ ] Create `internal/domain/service.go` (ProductService interface) +- [ ] Create `internal/domain/cache.go` (ProductCache interface) +- [ ] Create `internal/domain/events.go` (EventPublisher interface + topic constants) +- [ ] Update `internal/domain/product.go` (add Category.ParentID field) + +### Phase 2: Data Access (Repository Layer) + +- [ ] Create `internal/repository/product_repository.go` + - [ ] `CreateProduct` (with inventory row creation) + - [ ] `GetProductByID` (with Category preload) + - [ ] `ListProducts` (with filtering, sorting, pagination, full-text search) + - [ ] `UpdateProduct` + - [ ] `DeleteProduct` + - [ ] `CreateCategory` + - [ ] `ListCategories` + - [ ] `GetCategoryByID` + - [ ] `GetCategoryBySlug` + +### Phase 3: Caching (Cache Layer) + +- [ ] Create `internal/cache/product_cache.go` + - [ ] `GetProduct` / `SetProduct` / `DeleteProduct` + - [ ] `GetProductList` / `SetProductList` + - [ ] `InvalidateProductList` (SCAN + DEL pattern) + - [ ] Cache key generation helper + +### Phase 4: Business Logic (Service Layer) + +- [ ] Create `internal/service/product_service.go` + - [ ] `CreateProduct` (validate → create → cache → publish event) + - [ ] `GetProduct` (cache-first read) + - [ ] `ListProducts` (cache-first read with filter hashing) + - [ ] `UpdateProduct` (validate → update → invalidate cache → publish event) + - [ ] `DeleteProduct` (delete → invalidate cache → publish event) + - [ ] `CreateCategory` (validate → create) + - [ ] `ListCategories` (direct repo call) + - [ ] `normalizeFilter` helper (defaults + validation) + +### Phase 5: HTTP Interface (Handler Layer) + +- [ ] Create `internal/handler/product_handler.go` + - [ ] `ListProducts` (parse query params → filter → respond) + - [ ] `GetProduct` (parse UUID → respond) + - [ ] `CreateProduct` (bind JSON → validate → respond) + - [ ] `UpdateProduct` (bind JSON → validate → respond) + - [ ] `DeleteProduct` (parse UUID → respond) + - [ ] `ListCategories` (respond) + - [ ] `CreateCategory` (bind JSON → validate → respond) + - [ ] `handleServiceError` (error → HTTP status mapping) + - [ ] `parseFilter` helper (query param parsing) + +### Phase 6: Routing (Route Layer) + +- [ ] Create `internal/route/product_route.go` + - [ ] Register public GET routes + - [ ] Register admin POST/PUT/DELETE routes + - [ ] Configure middleware (gateway-forwarded headers) + +### Phase 7: Bootstrap (Outer Layer) + +- [ ] Create `cmd/config.go` (config struct + loading) +- [ ] Create `cmd/dotenv.go` (.env file loading) +- [ ] Create `cmd/infrastructure.go` (DB + Redis setup) +- [ ] Create `cmd/kafka.go` (Kafka publisher setup) +- [ ] Create `cmd/run.go` (DI wiring) +- [ ] Create `cmd/server.go` (Gin router + health check) +- [ ] Create `main.go` (entry point) +- [ ] Add tsvector index bootstrapping +- [ ] Add graceful shutdown + +### Phase 8: Configuration & Deployment + +- [ ] Create `.env.example` +- [ ] Create `Dockerfile` (multi-stage: golang builder → alpine runner) +- [ ] Update `go.mod` with required dependencies +- [ ] Verify docker-compose.yml integration (port 8082, health check) + +### Phase 9: Testing & Validation + +- [ ] Write unit tests for service layer (`service/product_service_test.go`) +- [ ] Write unit tests for repository layer (with test DB) +- [ ] Write integration tests (full HTTP flow with real DB + Redis) +- [ ] Smoke test: `curl http://localhost:8082/health` +- [ ] Smoke test: Create category → Create product → List products → Get product +- [ ] Cache validation test: Update product → verify cache miss on next GET +- [ ] `go test ./...` passes with 0 failures + +--- + +## 15. File Structure + +### Final Directory Layout + +``` +services/product-service/ +├── main.go # Entry point +├── go.mod # Go module definition +├── go.sum # Dependency lock file +├── Dockerfile # Multi-stage build +├── .env.example # Environment template +│ +├── cmd/ +│ ├── config.go # Configuration loading +│ ├── dotenv.go # .env file loading +│ ├── infrastructure.go # Database + Redis setup +│ ├── kafka.go # Kafka producer setup +│ ├── run.go # Main orchestration (DI wiring) +│ └── server.go # Gin router + health check +│ +├── internal/ +│ ├── domain/ +│ │ ├── product.go # Entities (Product, Category, Inventory) + DTOs ✅ +│ │ ├── errors.go # Error types ⚠️ +│ │ ├── repository.go # ProductRepository interface ❌ +│ │ ├── service.go # ProductService interface ❌ +│ │ ├── cache.go # ProductCache interface ❌ +│ │ └── events.go # EventPublisher interface + topic constants ❌ +│ │ +│ ├── repository/ +│ │ └── product_repository.go # GORM implementation ❌ +│ │ +│ ├── cache/ +│ │ └── product_cache.go # Redis implementation ❌ +│ │ +│ ├── service/ +│ │ └── product_service.go # Business logic ❌ +│ │ +│ ├── handler/ +│ │ └── product_handler.go # HTTP handlers ❌ +│ │ +│ ├── route/ +│ │ └── product_route.go # Route registration ❌ +│ │ +│ ├── middleware/ # (Optional — for future use) +│ │ +│ └── events/ # (Optional — for Kafka event structs) +│ +└── migrations/ # (Optional — for production migrations) + ├── 001_create_categories.up.sql + └── ... +``` + +**Legend:** +- ✅ = Already exists and complete +- ⚠️ = Exists but needs updates +- ❌ = Needs to be created + +--- + +## Appendix A: Key Design Decisions + +| Decision | Rationale | +|---|---| +| Cache-first reads | Reduces DB load for read-heavy product catalog traffic | +| Separate cache vs repo interfaces | Single Responsibility — each layer has one concern | +| No auth middleware in service | API Gateway handles all cross-cutting concerns | +| Async event publishing | Don't block HTTP response on Kafka availability | +| AutoMigrate for dev, SQL migrations for prod | Fast iteration in dev, auditable changes in prod | +| tsvector trigger on INSERT/UPDATE | Keeps search index in sync without application logic | +| SCAN for cache invalidation | Production-safe (non-blocking) alternative to KEYS | +| Inventory row created with product | Ensures inventory record exists before inventory-service manages it | + +## Appendix B: Dependencies + +From existing `go.mod`: + +```go +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.4.0 + github.com/segmentio/kafka-go v0.4.47 + gorm.io/driver/postgres v1.5.4 + gorm.io/gorm v1.25.5 +) + +replace github.com/auron/shared => ../../shared +``` + +## Appendix C: Health Check Configuration + +Per docker-compose.yml health check pattern: + +```yaml +product-service: + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8082/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s +``` + +The `/health` endpoint returns: + +```json +{ + "status": "healthy", + "service": "product-service", + "timestamp": "2025-01-01T00:00:00Z" +} +``` + +--- + +*Plan created: 2025-04-14* +*Based on: ecommerce-technical-plan.md §4.3, §5, §7, §10* \ No newline at end of file diff --git a/services/product-service/cmd/infrastructure.go b/services/product-service/cmd/infrastructure.go index 33514de..884acc1 100644 --- a/services/product-service/cmd/infrastructure.go +++ b/services/product-service/cmd/infrastructure.go @@ -33,11 +33,16 @@ func setupDatabase(databaseURL string) (*gorm.DB, error) { } func runMigrations(db *gorm.DB) error { - if err := db.AutoMigrate(&domain.Category{}, &domain.Product{}, &domain.Inventory{}); err != nil { - return err + // Skip AutoMigrate if tables already exist — GORM generates malformed ALTER + // statements when column types use precision specifiers (e.g. numeric(12,2)) + // that differ only in name from what PostgreSQL reports. applySearchIndex + // is always re-run because its statements are idempotent (IF NOT EXISTS / OR REPLACE). + if !db.Migrator().HasTable(&domain.Product{}) { + if err := db.AutoMigrate(&domain.Category{}, &domain.Product{}, &domain.Inventory{}); err != nil { + return err + } } - // Apply tsvector trigger for full-text search (idempotent raw SQL) return applySearchIndex(db) } From 3aeabae4bab6b2f3500e62483b7830817c3b452c Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 17:40:26 +0700 Subject: [PATCH 09/15] fix: Resolve Stripe webhook and PaymentIntent compatibility issues - Use ConstructEventWithOptions with IgnoreAPIVersionMismatch:true so the payment-service accepts webhooks from Stripe CLI (API 2024-04-10) while stripe-go v76 uses API version 2023-10-16 - Add AllowRedirects:"never" to PaymentIntent creation so redirect-based payment methods (iDEAL, BACS etc.) are excluded and no return_url is required at confirmation; cards continue to work with stripe.confirmPayment() Co-Authored-By: Claude Sonnet 4.6 --- services/payment-service/internal/client/stripe_client.go | 3 ++- services/payment-service/internal/service/payment_service.go | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/services/payment-service/internal/client/stripe_client.go b/services/payment-service/internal/client/stripe_client.go index e1af983..cc4bcda 100644 --- a/services/payment-service/internal/client/stripe_client.go +++ b/services/payment-service/internal/client/stripe_client.go @@ -24,7 +24,8 @@ func (c *stripeClient) CreatePaymentIntent(_ context.Context, amount float64, cu Amount: stripe.Int64(int64(amount * 100)), Currency: stripe.String(currency), AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ - Enabled: stripe.Bool(true), + Enabled: stripe.Bool(true), + AllowRedirects: stripe.String("never"), }, Metadata: metadata, } diff --git a/services/payment-service/internal/service/payment_service.go b/services/payment-service/internal/service/payment_service.go index 3147c1c..b58343c 100644 --- a/services/payment-service/internal/service/payment_service.go +++ b/services/payment-service/internal/service/payment_service.go @@ -153,7 +153,9 @@ func (s *PaymentService) HandleStripeWebhook(ctx context.Context, payload []byte return fmt.Errorf("webhook: unmarshal event: %w", err) } } else { - event, err = webhook.ConstructEvent(payload, signature, s.webhookSecret) + event, err = webhook.ConstructEventWithOptions(payload, signature, s.webhookSecret, webhook.ConstructEventOptions{ + IgnoreAPIVersionMismatch: true, + }) if err != nil { return domain.ErrInvalidWebhookSignature } From 7c85823ff83e0dda01573c89d75495138cc2814c Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 17:44:20 +0700 Subject: [PATCH 10/15] docs: Update API curl test guide with verified webhook flow and real responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 30 endpoints confirmed working end-to-end including the full Stripe payment flow: order → Kafka → PaymentIntent → CLI confirm → webhook → status completed. Documents the stripe-go API version mismatch workaround and allow_redirects:never PaymentIntent configuration. Co-Authored-By: Claude Sonnet 4.6 --- API_CURL_TESTS.md | 664 ++++++++++++++++++++++++++-------------------- 1 file changed, 374 insertions(+), 290 deletions(-) diff --git a/API_CURL_TESTS.md b/API_CURL_TESTS.md index a821cb4..ac36375 100644 --- a/API_CURL_TESTS.md +++ b/API_CURL_TESTS.md @@ -2,6 +2,9 @@ All requests go through the API Gateway on `http://localhost:8080`. +> **Tested on:** 2026-05-28 against the full docker-compose stack. +> All endpoints verified working unless noted. + --- ## Setup @@ -14,67 +17,76 @@ Run the commands below **in order** — later steps depend on tokens and IDs fro --- -## 1. Auth - -### Register a customer +## 1. Gateway Health ```bash -curl -s -X POST $BASE/auth/register \ - -H "Content-Type: application/json" \ - -d '{ - "email": "customer@auron.test", - "password": "password123", - "confirm_password": "password123", - "name": "Test Customer" - }' | jq +curl -s http://localhost:8080/api/health | jq ``` -Expected: `{ "success": true, "data": { "id", "email", "name", "role": "customer" } }` +**Response:** +```json +{ "service": "auron-api", "status": "healthy" } +``` --- -### Register an admin +## 2. Auth + +### Register a customer ```bash curl -s -X POST $BASE/auth/register \ -H "Content-Type: application/json" \ -d '{ - "email": "admin@auron.test", + "email": "customer@auron.test", "password": "password123", "confirm_password": "password123", - "name": "Test Admin", - "role": "admin" + "name": "Test Customer" }' | jq ``` +**Response:** +```json +{ + "data": { "id": "", "email": "customer@auron.test", "name": "Test Customer", "role": "customer" }, + "success": true +} +``` + +> **Note:** `role` field in register request is ignored for security — all new accounts are `customer`. +> To create an admin, update the role directly in the DB: +> ```bash +> docker exec psql -U auron -d users_db \ +> -c "UPDATE users SET role='admin' WHERE email='admin@auron.test';" +> ``` + --- -### Login as customer — save token +### Login — save tokens ```bash CUSTOMER_TOKEN=$(curl -s -X POST $BASE/auth/login \ -H "Content-Type: application/json" \ - -d '{ - "email": "customer@auron.test", - "password": "password123" - }' | jq -r '.access_token') - -echo "Customer token: $CUSTOMER_TOKEN" -``` - ---- + -d '{"email": "customer@auron.test", "password": "password123"}' \ + | jq -r '.access_token') -### Login as admin — save token +REFRESH_TOKEN=$(curl -s -X POST $BASE/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "customer@auron.test", "password": "password123"}' \ + | jq -r '.refresh_token') -```bash ADMIN_TOKEN=$(curl -s -X POST $BASE/auth/login \ -H "Content-Type: application/json" \ - -d '{ - "email": "admin@auron.test", - "password": "password123" - }' | jq -r '.access_token') + -d '{"email": "admin@auron.test", "password": "password123"}' \ + | jq -r '.access_token') +``` -echo "Admin token: $ADMIN_TOKEN" +**Response:** +```json +{ + "access_token": "eyJhbGci...", + "refresh_token": "eyJhbGci..." +} ``` --- @@ -82,19 +94,15 @@ echo "Admin token: $ADMIN_TOKEN" ### Refresh token ```bash -REFRESH_TOKEN=$(curl -s -X POST $BASE/auth/login \ - -H "Content-Type: application/json" \ - -d '{ - "email": "customer@auron.test", - "password": "password123" - }' | jq -r '.refresh_token') - curl -s -X POST $BASE/auth/refresh \ -H "Content-Type: application/json" \ -d "{\"refresh_token\": \"$REFRESH_TOKEN\"}" | jq ``` -Expected: `{ "access_token": "...", "refresh_token": "..." }` +**Response:** +```json +{ "access_token": "eyJhbGci...", "refresh_token": "eyJhbGci..." } +``` --- @@ -107,20 +115,28 @@ curl -s -X POST $BASE/auth/logout \ -d "{\"refresh_token\": \"$REFRESH_TOKEN\"}" | jq ``` -Expected: `{ "success": true, "message": "logged out" }` +**Response:** +```json +{ "success": true, "message": "logged out" } +``` --- -## 2. User Profile +## 3. User Profile ### Get profile ```bash -curl -s -X GET $BASE/users/me \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +curl -s $BASE/users/me -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq ``` -Expected: `{ "success": true, "data": { "id", "email", "name", "role" } }` +**Response:** +```json +{ + "data": { "id": "", "email": "customer@auron.test", "name": "Test Customer", "role": "customer" }, + "success": true +} +``` --- @@ -130,19 +146,25 @@ Expected: `{ "success": true, "data": { "id", "email", "name", "role" } }` curl -s -X PUT $BASE/users/me \ -H "Authorization: Bearer $CUSTOMER_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ "name": "Updated Customer Name" }' | jq + -d '{"name": "Updated Name"}' | jq ``` -Expected: `{ "success": true, "data": { ... } }` +**Response:** +```json +{ + "data": { "id": "", "email": "customer@auron.test", "name": "Updated Name", "role": "customer" }, + "success": true +} +``` --- -## 3. Addresses +## 4. Addresses -### Add address +### Add address — save ID ```bash -curl -s -X POST $BASE/users/me/addresses \ +ADDRESS_ID=$(curl -s -X POST $BASE/users/me/addresses \ -H "Authorization: Bearer $CUSTOMER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -152,25 +174,15 @@ curl -s -X POST $BASE/users/me/addresses \ "country": "Indonesia", "postal_code": "10110", "is_default": true - }' | jq -``` - -Expected: `{ "success": true, "data": { "id", "label", "street", "city", ... } }` - -```bash -# Save address ID for later -ADDRESS_ID=$(curl -s -X POST $BASE/users/me/addresses \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "label": "Office", - "street": "456 Business Ave", - "city": "Bandung", - "country": "Indonesia", - "is_default": false }' | jq -r '.data.id') +``` -echo "Address ID: $ADDRESS_ID" +**Response:** +```json +{ + "data": { "id": "", "label": "Home", "street": "123 Main St", "city": "Jakarta", "country": "Indonesia", "postal_code": "10110", "is_default": true }, + "success": true +} ``` --- @@ -178,11 +190,16 @@ echo "Address ID: $ADDRESS_ID" ### Get all addresses ```bash -curl -s -X GET $BASE/users/me/addresses \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +curl -s $BASE/users/me/addresses -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq ``` -Expected: `{ "success": true, "data": [ ... ] }` +**Response:** +```json +{ + "data": [{ "id": "", "label": "Home", "street": "123 Main St", "city": "Jakarta", ... }], + "success": true +} +``` --- @@ -192,7 +209,15 @@ Expected: `{ "success": true, "data": [ ... ] }` curl -s -X PUT $BASE/users/me/addresses/$ADDRESS_ID \ -H "Authorization: Bearer $CUSTOMER_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ "city": "Surabaya" }' | jq + -d '{"city": "Surabaya"}' | jq +``` + +**Response:** +```json +{ + "data": { "id": "", "city": "Surabaya", ... }, + "success": true +} ``` --- @@ -204,52 +229,53 @@ curl -s -X DELETE $BASE/users/me/addresses/$ADDRESS_ID \ -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq ``` -Expected: `{ "success": true, "message": "address deleted" }` +**Response:** +```json +{ "success": true, "message": "address deleted" } +``` --- -## 4. Categories (admin only for write) +## 5. Categories -### Get all categories — public +### Get all — public ```bash -curl -s -X GET $BASE/categories | jq +curl -s $BASE/categories | jq +``` + +**Response:** +```json +{ + "data": [{ "id": "", "name": "Electronics", "slug": "electronics", "created_at": "..." }], + "success": true +} ``` --- -### Create category — admin +### Create — admin only, save ID ```bash CATEGORY_ID=$(curl -s -X POST $BASE/categories \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ - "name": "Electronics", - "slug": "electronics" - }' | jq -r '.data.id') - -echo "Category ID: $CATEGORY_ID" + -d '{"name": "Electronics", "slug": "electronics"}' | jq -r '.data.id') ``` -### Create sub-category - -```bash -curl -s -X POST $BASE/categories \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{ - \"name\": \"Smartphones\", - \"slug\": \"smartphones\", - \"parent_id\": \"$CATEGORY_ID\" - }" | jq +**Response:** +```json +{ + "data": { "id": "", "name": "Electronics", "slug": "electronics", "created_at": "..." }, + "success": true +} ``` --- -## 5. Products (admin only for write) +## 6. Products -### Create product — admin +### Create — admin only, save ID ```bash PRODUCT_ID=$(curl -s -X POST $BASE/products \ @@ -262,369 +288,383 @@ PRODUCT_ID=$(curl -s -X POST $BASE/products \ \"price\": 15999000, \"is_active\": true }" | jq -r '.data.id') - -echo "Product ID: $PRODUCT_ID" ``` ---- - -### List products — public - -```bash -curl -s -X GET "$BASE/products" | jq +**Response:** +```json +{ + "data": { "id": "", "name": "iPhone 15 Pro", "price": 15999000, "is_active": true, ... }, + "success": true +} ``` --- -### List products with filters +### List — public, with filters ```bash -# Search by name -curl -s -X GET "$BASE/products?q=iphone" | jq +# All products +curl -s "$BASE/products" | jq '.data[0] | {id,name,price}' -# Filter by category -curl -s -X GET "$BASE/products?category_id=$CATEGORY_ID" | jq +# Full-text search +curl -s "$BASE/products?q=iphone" | jq '{total: .meta.total, first: .data[0].name}' -# Price range -curl -s -X GET "$BASE/products?min_price=10000000&max_price=20000000" | jq +# Filter + sort + paginate +curl -s "$BASE/products?category_id=$CATEGORY_ID&sort=price_asc&page=1&limit=5" | jq +``` -# Sort + paginate -curl -s -X GET "$BASE/products?sort=price_asc&page=1&limit=5" | jq +**Response (list):** +```json +{ + "data": [{ "id": "", "name": "iPhone 15 Pro", "price": 15999000, "is_active": true, ... }], + "meta": { "page": 1, "limit": 20, "total": 1 }, + "success": true +} ``` --- -### Get single product — public +### Get by ID — public ```bash -curl -s -X GET "$BASE/products/$PRODUCT_ID" | jq +curl -s "$BASE/products/$PRODUCT_ID" | jq '.data | {id,name,price}' +``` + +**Response:** +```json +{ + "data": { "id": "", "name": "iPhone 15 Pro", "price": 15999000, ... }, + "success": true +} ``` --- -### Update product — admin +### Update — admin only ```bash curl -s -X PUT "$BASE/products/$PRODUCT_ID" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d "{ - \"category_id\": \"$CATEGORY_ID\", - \"name\": \"iPhone 15 Pro Max\", - \"description\": \"Apple iPhone 15 Pro Max 512GB\", - \"price\": 18999000, - \"is_active\": true - }" | jq + -d "{\"category_id\":\"$CATEGORY_ID\",\"name\":\"iPhone 15 Pro Max\",\"description\":\"512GB\",\"price\":18999000,\"is_active\":true}" | jq '.data | {name,price}' +``` + +**Response:** +```json +{ "data": { "name": "iPhone 15 Pro Max", "price": 18999000, ... }, "success": true } ``` --- -### Delete product — admin +### Delete — admin only ```bash -curl -s -X DELETE "$BASE/products/$PRODUCT_ID" \ - -H "Authorization: Bearer $ADMIN_TOKEN" | jq +curl -s -X DELETE "$BASE/products/$PRODUCT_ID" -H "Authorization: Bearer $ADMIN_TOKEN" | jq ``` -> Re-create the product after deletion for subsequent tests: - -```bash -PRODUCT_ID=$(curl -s -X POST $BASE/products \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{ - \"category_id\": \"$CATEGORY_ID\", - \"name\": \"iPhone 15 Pro\", - \"description\": \"Apple iPhone 15 Pro 256GB\", - \"price\": 15999000, - \"is_active\": true - }" | jq -r '.data.id') -echo "Product ID: $PRODUCT_ID" +**Response:** +```json +{ "success": true, "message": "product deleted" } ``` --- -## 6. Inventory (GET public, PUT admin) +## 7. Inventory -### Get stock — public +### Get stock — **public, no auth required** ```bash -curl -s -X GET "$BASE/inventory/$PRODUCT_ID" | jq +curl -s "$BASE/inventory/$PRODUCT_ID" | jq +``` + +**Response:** +```json +{ + "data": { "product_id": "", "total_quantity": 50, "reserved_quantity": 0, "available_quantity": 50, "updated_at": "..." }, + "success": true +} ``` -Expected: `{ "success": true, "data": { "product_id", "total_quantity", "reserved_quantity", "available_quantity" } }` +> Returns 404 if inventory has never been set for this product. --- -### Set stock — admin +### Set stock — admin only ```bash curl -s -X PUT "$BASE/inventory/$PRODUCT_ID" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ "total_quantity": 50 }' | jq + -d '{"total_quantity": 50}' | jq +``` + +**Response:** +```json +{ + "data": { "product_id": "", "total_quantity": 50, "reserved_quantity": 0, "available_quantity": 50, "updated_at": "..." }, + "success": true +} ``` --- -## 7. Cart +## 8. Cart -### Add item to cart +### Add item ```bash curl -s -X POST "$BASE/cart/items" \ -H "Authorization: Bearer $CUSTOMER_TOKEN" \ -H "Content-Type: application/json" \ - -d "{ - \"product_id\": \"$PRODUCT_ID\", - \"quantity\": 2 - }" | jq + -d "{\"product_id\": \"$PRODUCT_ID\", \"quantity\": 2}" | jq '.data | {total, items_count: (.items|length)}' +``` + +**Response:** +```json +{ "data": { "total": 31998000, "items": [...] }, "success": true } ``` --- -### Get cart +### Get cart — save item ID ```bash -curl -s -X GET "$BASE/cart" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +CART_ITEM_ID=$(curl -s "$BASE/cart" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq -r '.data.items[0].id') ``` -Expected: `{ "success": true, "data": { "id", "items": [...], "total" } }` - -```bash -# Save cart item ID -CART_ITEM_ID=$(curl -s -X GET "$BASE/cart" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq -r '.data.items[0].id') -echo "Cart item ID: $CART_ITEM_ID" +**Response:** +```json +{ + "data": { "id": "", "user_id": "", "items": [{ "id": "", "product_id": "", "quantity": 2, "price": 15999000, "subtotal": 31998000, ... }], "total": 31998000 }, + "success": true +} ``` --- -### Update cart item quantity +### Update item quantity ```bash curl -s -X PUT "$BASE/cart/items/$CART_ITEM_ID" \ -H "Authorization: Bearer $CUSTOMER_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ "quantity": 1 }' | jq + -d '{"quantity": 1}' | jq '.data | {total, new_qty: .items[0].quantity}' +``` + +**Response:** +```json +{ "data": { "total": 15999000, "items": [{ "quantity": 1, ... }] }, "success": true } ``` --- -### Remove cart item +### Remove item ```bash -curl -s -X DELETE "$BASE/cart/items/$CART_ITEM_ID" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +curl -s -X DELETE "$BASE/cart/items/$CART_ITEM_ID" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq ``` -> Re-add item for checkout test: - -```bash -curl -s -X POST "$BASE/cart/items" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{ - \"product_id\": \"$PRODUCT_ID\", - \"quantity\": 1 - }" | jq +**Response:** +```json +{ "success": true, "message": "item removed from cart" } ``` --- -## 8. Orders +## 9. Orders -### Place order (clears cart automatically) +### Place order — clears cart automatically ```bash +# Re-add item first +curl -s -X POST "$BASE/cart/items" -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"product_id\": \"$PRODUCT_ID\", \"quantity\": 1}" > /dev/null + ORDER_ID=$(curl -s -X POST "$BASE/orders" \ -H "Authorization: Bearer $CUSTOMER_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ - "shipping_name": "Test Customer", - "shipping_address": "123 Main St, Jakarta 10110" - }' | jq -r '.data.id') - -echo "Order ID: $ORDER_ID" + -d '{"shipping_name": "Test Customer", "shipping_address": "123 Main St, Jakarta 10110"}' \ + | jq -r '.data.id') ``` -Expected: `{ "success": true, "data": { "id", "status": "pending", "total_amount", "items": [...] } }` +**Response:** +```json +{ + "data": { + "id": "", "status": "pending", "total_amount": 15999000, + "items": [{ "product_id": "", "product_name": "iPhone 15 Pro", "quantity": 1, "price": 15999000, "subtotal": 15999000 }], + "shipping_name": "Test Customer", "shipping_address": "123 Main St, Jakarta 10110" + }, + "success": true +} +``` --- -### Get all orders +### Verify cart was cleared ```bash -curl -s -X GET "$BASE/orders" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq - -# With pagination -curl -s -X GET "$BASE/orders?page=1&limit=10" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +curl -s "$BASE/cart" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data | {total, items_count: (.items|length)}' ``` +**Response:** `{ "total": 0, "items_count": 0 }` ✅ + --- -### Get single order +### Get all orders ```bash -curl -s -X GET "$BASE/orders/$ORDER_ID" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +curl -s "$BASE/orders" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '{total: .meta.total}' +``` + +**Response:** +```json +{ "data": [...], "meta": { "page": 1, "limit": 10, "total": 1 }, "success": true } ``` --- -### Verify cart was cleared after order +### Get order by ID ```bash -curl -s -X GET "$BASE/cart" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +curl -s "$BASE/orders/$ORDER_ID" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data | {id,status,total_amount}' ``` -Expected: empty items array or cart not found. +**Response:** +```json +{ "data": { "id": "", "status": "pending", "total_amount": 15999000 }, "success": true } +``` --- ### Cancel order ```bash -curl -s -X PUT "$BASE/orders/$ORDER_ID/cancel" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +curl -s -X PUT "$BASE/orders/$ORDER_ID/cancel" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data | {id,status}' ``` -Expected: `{ "success": true, "data": { "status": "cancelled" } }` - -> Place a new order for payment tests: - -```bash -curl -s -X POST "$BASE/cart/items" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"product_id\": \"$PRODUCT_ID\", \"quantity\": 1}" | jq - -ORDER_ID=$(curl -s -X POST "$BASE/orders" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"shipping_name":"Test Customer","shipping_address":"123 Main St"}' \ - | jq -r '.data.id') -echo "New Order ID: $ORDER_ID" +**Response:** +```json +{ "data": { "id": "", "status": "cancelled" }, "success": true } ``` --- -## 9. Payments +## 10. Payments -> Payment is created **asynchronously** after the order is placed via Kafka (`order.created` → `payment-service`). -> Wait a few seconds after placing an order before querying payment. +> Payment is created **asynchronously** after `POST /orders` via Kafka (`order.created` → payment-service). +> The payment-service calls Stripe to create a PaymentIntent and stores the `client_secret`. -### Get payment by order ID (includes Stripe client_secret) +### Get payment by order ID — includes Stripe `client_secret` ```bash -sleep 3 # Wait for Kafka event processing +# Place a fresh order first, then wait ~1-3s for Kafka +sleep 3 -curl -s -X GET "$BASE/payments/order/$ORDER_ID" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +curl -s "$BASE/payments/order/$ORDER_ID" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data | {id,status,amount,currency,stripe_payment_intent_id,client_secret}' ``` -Expected: +**Response:** ```json { - "success": true, "data": { - "id": "...", - "order_id": "...", + "id": "", + "order_id": "", + "user_id": "", + "status": "pending", "amount": 15999000, "currency": "usd", - "status": "pending", - "stripe_payment_intent_id": "pi_...", - "client_secret": "pi_..._secret_..." - } + "stripe_payment_intent_id": "pi_3Tc0zrRr2KxYotum0gxE3rT4", + "client_secret": "pi_3Tc0zrRr2KxYotum0gxE3rT4_secret_...", + "created_at": "..." + }, + "success": true } ``` -```bash -# Save payment ID -PAYMENT_ID=$(curl -s -X GET "$BASE/payments/order/$ORDER_ID" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq -r '.data.id') -echo "Payment ID: $PAYMENT_ID" -``` +> Use `client_secret` in the frontend with `stripe.confirmPayment()` to complete the payment. +> Requesting before Kafka processes → `404 payment not found`. --- -### Get payment by ID +### Get payment by ID — no `client_secret` ```bash -curl -s -X GET "$BASE/payments/$PAYMENT_ID" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +curl -s "$BASE/payments/$PAYMENT_ID" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data | {id,status,amount}' +``` + +**Response:** +```json +{ + "data": { "id": "", "status": "pending", "amount": 15999000, "currency": "usd", ... }, + "success": true +} ``` -> Note: This endpoint uses `PaymentResponse` (no `client_secret`). Use `/payments/order/:order_id` to get the secret. +> `client_secret` is omitted from this response. Use `/payments/order/:order_id` for checkout. --- -### Stripe webhook (dev mode — no signature required when STRIPE_WEBHOOK_SECRET is empty) +### Stripe webhook — full end-to-end test ```bash -curl -s -X POST "$BASE/payments/webhook/stripe" \ - -H "Content-Type: application/json" \ - -d "{ - \"id\": \"evt_test_001\", - \"type\": \"payment_intent.succeeded\", - \"data\": { - \"object\": { - \"id\": \"pi_test\", - \"object\": \"payment_intent\", - \"amount\": 15999000, - \"currency\": \"usd\", - \"status\": \"succeeded\", - \"metadata\": { - \"payment_id\": \"$PAYMENT_ID\", - \"order_id\": \"$ORDER_ID\" - } - } - } - }" | jq -``` +# 1. Start the Stripe CLI listener (run once in a separate terminal) +stripe listen --forward-to localhost:8080/api/payments/webhook/stripe +# Copy the whsec_... secret into STRIPE_WEBHOOK_SECRET env var and restart payment-service -Expected: `{ "received": true }` +# 2. Place an order and get the PaymentIntent ID +curl -s -X POST "$BASE/cart/items" -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" -d "{\"product_id\":\"$PRODUCT_ID\",\"quantity\":1}" > /dev/null ---- +ORDER_ID=$(curl -s -X POST "$BASE/orders" -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"shipping_name":"Test","shipping_address":"123 St"}' | jq -r '.data.id') -### Verify payment status updated to completed +sleep 3 # wait for Kafka -```bash -curl -s -X GET "$BASE/payments/$PAYMENT_ID" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data.status' -``` +PAYMENT=$(curl -s "$BASE/payments/order/$ORDER_ID" -H "Authorization: Bearer $CUSTOMER_TOKEN") +PI=$(echo $PAYMENT | jq -r '.data.stripe_payment_intent_id') +PAYMENT_ID=$(echo $PAYMENT | jq -r '.data.id') -Expected: `"completed"` +# 3. Confirm the PaymentIntent using the Stripe CLI with a test card +stripe payment_intents confirm $PI --payment-method=pm_card_visa ---- - -## 10. Gateway health +# 4. Verify status updated to completed +sleep 3 +curl -s "$BASE/payments/$PAYMENT_ID" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data | {id, status}' +``` -```bash -curl -s http://localhost:8080/api/health | jq +**Response after confirmation:** +```json +{ "data": { "id": "", "status": "completed" }, "success": true } ``` -Expected: `{ "status": "healthy", "service": "auron-api" }` +**Notes:** +- Always returns `{ "received": true }` with HTTP 200 (Stripe retries on non-2xx) +- Handles: `payment_intent.succeeded` → status `completed`, `payment_intent.payment_failed` → status `failed` +- PaymentIntent created with `allow_redirects: never` so no `return_url` is needed at confirmation +- stripe-go v76 uses API `2023-10-16`; `IgnoreAPIVersionMismatch: true` set so CLI events (API `2024-04-10`) are accepted +- In dev: set `STRIPE_WEBHOOK_SECRET=` (empty) to skip signature verification entirely --- -## Error cases +## 11. Error Cases -### Unauthenticated request to protected endpoint +### No auth token → 401 ```bash -curl -s -X GET $BASE/users/me | jq +curl -s $BASE/users/me | jq ``` -Expected: 401 +```json +{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Authorization header is required" } } +``` --- -### Customer accessing admin endpoint +### Customer accessing admin endpoint → 403 ```bash curl -s -X POST $BASE/categories \ @@ -633,11 +673,13 @@ curl -s -X POST $BASE/categories \ -d '{"name":"Hack","slug":"hack"}' | jq ``` -Expected: 403 +```json +{ "success": false, "error": { "code": "FORBIDDEN", "message": "Insufficient permissions" } } +``` --- -### Place order with empty cart +### Order with empty cart → 400 ```bash curl -s -X POST "$BASE/orders" \ @@ -646,15 +688,57 @@ curl -s -X POST "$BASE/orders" \ -d '{"shipping_name":"Test","shipping_address":"Somewhere"}' | jq ``` -Expected: 400 / cart empty error +```json +{ "success": false, "error": "cart is empty" } +``` --- -### Get payment before Kafka has processed it +### Payment not yet processed by Kafka → 404 ```bash -curl -s -X GET "$BASE/payments/order/$ORDER_ID" \ - -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +# Immediately after POST /orders (before Kafka processes) +curl -s "$BASE/payments/order/$ORDER_ID" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq ``` -Expected: 404 (payment not yet created) +```json +{ "success": false, "error": "payment not found" } +``` + +--- + +## Summary — Endpoint Status + +| Endpoint | Method | Auth | Status | +|----------|--------|------|--------| +| `/api/health` | GET | ❌ | ✅ | +| `/api/auth/register` | POST | ❌ | ✅ | +| `/api/auth/login` | POST | ❌ | ✅ | +| `/api/auth/refresh` | POST | ❌ | ✅ | +| `/api/auth/logout` | POST | JWT | ✅ | +| `/api/users/me` | GET | JWT | ✅ | +| `/api/users/me` | PUT | JWT | ✅ | +| `/api/users/me/addresses` | GET | JWT | ✅ | +| `/api/users/me/addresses` | POST | JWT | ✅ | +| `/api/users/me/addresses/:id` | PUT | JWT | ✅ | +| `/api/users/me/addresses/:id` | DELETE | JWT | ✅ | +| `/api/categories` | GET | ❌ | ✅ | +| `/api/categories` | POST | Admin | ✅ | +| `/api/products` | GET | ❌ | ✅ | +| `/api/products` | POST | Admin | ✅ | +| `/api/products/:id` | GET | ❌ | ✅ | +| `/api/products/:id` | PUT | Admin | ✅ | +| `/api/products/:id` | DELETE | Admin | ✅ | +| `/api/inventory/:product_id` | GET | ❌ | ✅ | +| `/api/inventory/:product_id` | PUT | Admin | ✅ | +| `/api/cart` | GET | JWT | ✅ | +| `/api/cart/items` | POST | JWT | ✅ | +| `/api/cart/items/:id` | PUT | JWT | ✅ | +| `/api/cart/items/:id` | DELETE | JWT | ✅ | +| `/api/orders` | GET | JWT | ✅ | +| `/api/orders` | POST | JWT | ✅ | +| `/api/orders/:id` | GET | JWT | ✅ | +| `/api/orders/:id/cancel` | PUT | JWT | ✅ | +| `/api/payments/order/:order_id` | GET | JWT | ✅ | +| `/api/payments/:id` | GET | JWT | ✅ | +| `/api/payments/webhook/stripe` | POST | Stripe-signed | ✅ (verified end-to-end with CLI confirm) | From 357ee12cc69f0e1a5f4bc66b5aacc5ce32b1be8d Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 17:53:36 +0700 Subject: [PATCH 11/15] docs: Add API documentation and Postman collection - API_DOCS.md covers all 30 endpoints across 8 services with request/response shapes, query parameters, auth requirements, and error codes - Auron.postman_collection.json provides a ready-to-import Postman collection with collection variables, Bearer auth pre-configured, and test scripts that auto-save IDs (tokens, order_id, product_id, etc.) after each response Co-Authored-By: Claude Sonnet 4.6 --- API_DOCS.md | 988 ++++++++++++++++++++++++++++++++++ Auron.postman_collection.json | 809 ++++++++++++++++++++++++++++ 2 files changed, 1797 insertions(+) create mode 100644 API_DOCS.md create mode 100644 Auron.postman_collection.json diff --git a/API_DOCS.md b/API_DOCS.md new file mode 100644 index 0000000..ba82cde --- /dev/null +++ b/API_DOCS.md @@ -0,0 +1,988 @@ +# Auron API Documentation + +Base URL: `http://localhost:8080` +All endpoints are prefixed with `/api`. + +--- + +## Table of Contents + +- [Authentication](#authentication) +- [Users](#users) +- [Products](#products) +- [Categories](#categories) +- [Cart](#cart) +- [Orders](#orders) +- [Payments](#payments) +- [Inventory](#inventory) +- [Health](#health) +- [Response Envelope](#response-envelope) +- [Error Codes](#error-codes) + +--- + +## Response Envelope + +All responses follow a consistent envelope format. + +**Success:** +```json +{ + "success": true, + "data": { ... } +} +``` + +**Paginated success:** +```json +{ + "success": true, + "data": [ ... ], + "meta": { + "page": 1, + "limit": 20, + "total": 100 + } +} +``` + +**Error:** +```json +{ + "success": false, + "error": "descriptive error message" +} +``` + +**Exceptions:** Auth token endpoints (`/login`, `/refresh`) return `access_token` and `refresh_token` at the top level (no `data` wrapper). The Stripe webhook endpoint returns `{"received": true}`. + +--- + +## Authentication + +Rate limited to **20 requests per minute** per IP. + +All auth routes are prefixed with `/api/auth`. + +--- + +### Register + +`POST /api/auth/register` + +Creates a new customer account. The `role` field is ignored for security — all registrations default to `customer`. + +**Request body:** +```json +{ + "email": "user@example.com", + "password": "securepass123", + "confirm_password": "securepass123", + "name": "Jane Doe" +} +``` + +| Field | Type | Required | Constraints | +|-------|------|----------|-------------| +| `email` | string | yes | valid email | +| `password` | string | yes | min 8 chars | +| `confirm_password` | string | yes | must match `password` | +| `name` | string | yes | — | + +**Response `201`:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "email": "user@example.com", + "name": "Jane Doe", + "role": "customer" + } +} +``` + +**Errors:** `400` invalid body · `409` email already exists + +--- + +### Login + +`POST /api/auth/login` + +Returns JWT tokens. Tokens are also set as `HttpOnly` cookies (`access_token`, `refresh_token`). + +**Request body:** +```json +{ + "email": "user@example.com", + "password": "securepass123" +} +``` + +**Response `200`:** +```json +{ + "access_token": "eyJ...", + "refresh_token": "eyJ..." +} +``` + +**Errors:** `400` invalid body · `401` invalid credentials + +--- + +### Refresh Token + +`POST /api/auth/refresh` + +Exchange a refresh token for a new access token. Accepts the token from the request body or the `refresh_token` cookie. + +**Request body:** +```json +{ + "refresh_token": "eyJ..." +} +``` + +**Response `200`:** +```json +{ + "access_token": "eyJ...", + "refresh_token": "eyJ..." +} +``` + +**Errors:** `400` missing token · `401` invalid or expired token + +--- + +### Logout + +`POST /api/auth/logout` +**Auth required.** + +Revokes the refresh token. Clears both cookies. Accepts the token from the request body or the `refresh_token` cookie. + +**Request body:** +```json +{ + "refresh_token": "eyJ..." +} +``` + +**Response `200`:** +```json +{ + "success": true, + "message": "logged out" +} +``` + +**Errors:** `400` missing token · `401` invalid token + +--- + +## Users + +All routes require a valid `Authorization: Bearer ` header. + +--- + +### Get Profile + +`GET /api/users/me` + +**Response `200`:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "email": "user@example.com", + "name": "Jane Doe", + "role": "customer" + } +} +``` + +--- + +### Update Profile + +`PUT /api/users/me` + +All fields are optional — only provided fields are updated. + +**Request body:** +```json +{ + "name": "Jane Smith", + "email": "new@example.com", + "password": "newpass123" +} +``` + +| Field | Type | Constraints | +|-------|------|-------------| +| `name` | string | — | +| `email` | string | valid email | +| `password` | string | min 8 chars | + +**Response `200`:** same shape as Get Profile + +**Errors:** `400` validation · `409` email taken + +--- + +### Add Address + +`POST /api/users/me/addresses` + +**Request body:** +```json +{ + "label": "Home", + "street": "123 Main St", + "city": "Jakarta", + "state": "DKI Jakarta", + "country": "Indonesia", + "postal_code": "12345", + "is_default": true +} +``` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `street` | string | yes | — | +| `city` | string | yes | — | +| `country` | string | yes | — | +| `label` | string | no | e.g. "Home", "Office" | +| `state` | string | no | — | +| `postal_code` | string | no | — | +| `is_default` | bool | no | defaults to `false` | + +**Response `201`:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "label": "Home", + "street": "123 Main St", + "city": "Jakarta", + "state": "DKI Jakarta", + "country": "Indonesia", + "postal_code": "12345", + "is_default": true + } +} +``` + +--- + +### List Addresses + +`GET /api/users/me/addresses` + +**Response `200`:** +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "label": "Home", + "street": "123 Main St", + "city": "Jakarta", + "country": "Indonesia", + "is_default": true + } + ] +} +``` + +--- + +### Update Address + +`PUT /api/users/me/addresses/:id` + +All fields are optional — only provided fields are updated. + +**Request body:** same fields as Add Address (all optional) + +**Response `200`:** +```json +{ + "success": true, + "data": { ...address } +} +``` + +**Errors:** `400` invalid ID · `404` address not found + +--- + +### Delete Address + +`DELETE /api/users/me/addresses/:id` + +**Response `200`:** +```json +{ + "success": true, + "message": "address deleted" +} +``` + +**Errors:** `400` invalid ID · `404` address not found + +--- + +## Products + +GET endpoints are **public** (no auth required). POST, PUT, DELETE require **admin** role. + +--- + +### List Products + +`GET /api/products` + +Supports full-text search, filtering by category and price range, sorting, and pagination. + +**Query parameters:** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `q` | string | — | Full-text search across name and description | +| `category_id` | UUID | — | Filter by category | +| `min_price` | float | — | Minimum price | +| `max_price` | float | — | Maximum price | +| `sort` | string | — | `price_asc` · `price_desc` · `newest` · `name_asc` · `name_desc` | +| `page` | int | `1` | Page number | +| `limit` | int | `20` | Results per page | + +**Response `200`:** +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "category_id": "uuid", + "name": "Product Name", + "description": "...", + "price": 99.99, + "image_url": "https://...", + "is_active": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "category": { + "id": "uuid", + "name": "Electronics", + "slug": "electronics" + } + } + ], + "meta": { + "page": 1, + "limit": 20, + "total": 42 + } +} +``` + +--- + +### Get Product + +`GET /api/products/:id` + +**Response `200`:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "category_id": "uuid", + "name": "Product Name", + "description": "...", + "price": 99.99, + "image_url": "https://...", + "is_active": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } +} +``` + +**Errors:** `400` invalid UUID · `404` not found + +--- + +### Create Product + +`POST /api/products` +**Admin only.** + +**Request body:** +```json +{ + "category_id": "uuid", + "name": "Product Name", + "description": "Product description", + "price": 99.99, + "image_url": "https://example.com/image.jpg", + "is_active": true +} +``` + +| Field | Type | Required | Constraints | +|-------|------|----------|-------------| +| `category_id` | UUID | yes | must exist | +| `name` | string | yes | max 500 chars | +| `description` | string | yes | — | +| `price` | float | yes | greater than 0 | +| `image_url` | string | no | valid URL | +| `is_active` | bool | no | defaults to `true` | + +**Response `201`:** same shape as Get Product + +**Errors:** `400` validation · `401` unauthenticated · `403` not admin · `404` category not found · `409` product already exists + +--- + +### Update Product + +`PUT /api/products/:id` +**Admin only.** + +**Request body:** same as Create Product + +**Response `200`:** same shape as Get Product + +**Errors:** `400` · `401` · `403` · `404` + +--- + +### Delete Product + +`DELETE /api/products/:id` +**Admin only.** + +**Response `200`:** +```json +{ + "success": true, + "message": "product deleted" +} +``` + +**Errors:** `400` · `401` · `403` · `404` + +--- + +## Categories + +GET is **public**. POST requires **admin** role. + +--- + +### List Categories + +`GET /api/categories` + +**Response `200`:** +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "name": "Electronics", + "slug": "electronics", + "parent_id": null, + "created_at": "2026-01-01T00:00:00Z" + } + ] +} +``` + +--- + +### Create Category + +`POST /api/categories` +**Admin only.** + +**Request body:** +```json +{ + "name": "Electronics", + "slug": "electronics", + "parent_id": null +} +``` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `name` | string | yes | — | +| `slug` | string | yes | must be unique | +| `parent_id` | UUID | no | parent category UUID | + +**Response `201`:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "name": "Electronics", + "slug": "electronics", + "parent_id": null, + "created_at": "2026-01-01T00:00:00Z" + } +} +``` + +**Errors:** `400` · `401` · `403` · `409` slug already exists + +--- + +## Cart + +All routes require auth. Each user has exactly one cart; it is created automatically on first access. The cart is cleared automatically when an order is placed. + +--- + +### Get Cart + +`GET /api/cart` + +**Response `200`:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "user_id": "uuid", + "items": [ + { + "id": "uuid", + "cart_id": "uuid", + "product_id": "uuid", + "product_name": "Product Name", + "price": 99.99, + "quantity": 2, + "subtotal": 199.98, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } + ], + "total": 199.98, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } +} +``` + +--- + +### Add Item + +`POST /api/cart/items` + +If the product is already in the cart, quantity is incremented. + +**Request body:** +```json +{ + "product_id": "uuid", + "quantity": 2 +} +``` + +| Field | Type | Required | Constraints | +|-------|------|----------|-------------| +| `product_id` | UUID | yes | must exist and be active | +| `quantity` | int | yes | min 1 | + +**Response `200`:** same shape as Get Cart + +**Errors:** `400` invalid quantity · `404` product not found · `422` product inactive + +--- + +### Update Item + +`PUT /api/cart/items/:id` + +`:id` is the cart item UUID (not the product UUID). + +**Request body:** +```json +{ + "quantity": 3 +} +``` + +**Response `200`:** same shape as Get Cart + +**Errors:** `400` · `404` item not found + +--- + +### Remove Item + +`DELETE /api/cart/items/:id` + +`:id` is the cart item UUID. + +**Response `200`:** +```json +{ + "success": true, + "message": "item removed from cart" +} +``` + +**Errors:** `400` · `404` item not found + +--- + +## Orders + +All routes require auth. + +--- + +### List Orders + +`GET /api/orders` + +Returns orders belonging to the authenticated user, newest first. + +**Query parameters:** + +| Param | Type | Default | +|-------|------|---------| +| `page` | int | `1` | +| `limit` | int | `10` | + +**Response `200`:** +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "user_id": "uuid", + "status": "pending", + "total_amount": 199.98, + "shipping_name": "Jane Doe", + "shipping_address": "123 Main St, Jakarta", + "items": [ ... ], + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } + ], + "meta": { + "page": 1, + "limit": 10, + "total": 5 + } +} +``` + +**Order status values:** `pending` · `confirmed` · `processing` · `shipped` · `delivered` · `cancelled` + +--- + +### Create Order + +`POST /api/orders` + +Converts the user's current cart into an order. Reserves inventory, publishes `order.created` to Kafka (which triggers payment-service to create a Stripe PaymentIntent), and clears the cart. + +**Request body:** +```json +{ + "shipping_name": "Jane Doe", + "shipping_address": "123 Main St, Jakarta 12345" +} +``` + +**Response `201`:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "user_id": "uuid", + "status": "pending", + "total_amount": 199.98, + "shipping_name": "Jane Doe", + "shipping_address": "123 Main St, Jakarta 12345", + "items": [ + { + "id": "uuid", + "order_id": "uuid", + "product_id": "uuid", + "product_name": "Product Name", + "price": 99.99, + "quantity": 2, + "subtotal": 199.98, + "created_at": "2026-01-01T00:00:00Z" + } + ], + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } +} +``` + +**Errors:** `400` cart is empty · `401` · `500` internal + +--- + +### Get Order + +`GET /api/orders/:id` + +**Response `200`:** same shape as the object inside List Orders + +**Errors:** `400` invalid UUID · `403` not your order · `404` not found + +--- + +### Cancel Order + +`PUT /api/orders/:id/cancel` + +Only orders with status `pending`, `confirmed`, or `processing` can be cancelled. Releases reserved inventory. + +**Response `200`:** +```json +{ + "success": true, + "data": { ...order with status "cancelled" } +} +``` + +**Errors:** `400` invalid UUID · `403` · `404` · `409` order cannot be cancelled + +--- + +## Payments + +GET endpoints require auth. The Stripe webhook is **public** (Stripe signs its own payload). + +--- + +### Get Payment + +`GET /api/payments/:id` + +Returns the payment record for the authenticated user. + +**Response `200`:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "order_id": "uuid", + "user_id": "uuid", + "amount": 199.98, + "currency": "usd", + "status": "completed", + "stripe_payment_intent_id": "pi_...", + "failure_reason": "", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } +} +``` + +**Payment status values:** `pending` · `processing` · `completed` · `failed` · `refunded` + +**Errors:** `400` · `403` not your payment · `404` + +--- + +### Get Payment by Order + +`GET /api/payments/order/:order_id` + +Looks up the payment for a given order. Includes `client_secret` so the frontend can confirm the Stripe PaymentIntent via Stripe.js. + +**Response `200`:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "order_id": "uuid", + "user_id": "uuid", + "amount": 199.98, + "currency": "usd", + "status": "pending", + "stripe_payment_intent_id": "pi_...", + "client_secret": "pi_..._secret_...", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } +} +``` + +**Frontend payment flow:** +1. Create order → `POST /api/orders` +2. Fetch `client_secret` → `GET /api/payments/order/:order_id` +3. Confirm payment with Stripe.js using `client_secret` +4. Stripe sends webhook → payment status updates to `completed` + +**Errors:** `400` · `403` · `404` + +--- + +### Stripe Webhook + +`POST /api/payments/webhook/stripe` + +Internal endpoint for Stripe event delivery. Do not call this directly. + +Stripe signs every request with `Stripe-Signature`. The service verifies the signature using `STRIPE_WEBHOOK_SECRET`. Any non-2xx would cause Stripe to retry — the handler always returns `200`. + +**Handled events:** +- `payment_intent.succeeded` → status → `completed`, publishes `payment.completed` +- `payment_intent.payment_failed` → status → `failed`, publishes `payment.failed` +- `payment_intent.processing` → status → `processing` + +**Response `200`:** +```json +{ "received": true } +``` + +--- + +## Inventory + +GET is **public**. PUT requires **admin** role. + +--- + +### Get Inventory + +`GET /api/inventory/:product_id` + +**Response `200`:** +```json +{ + "success": true, + "data": { + "product_id": "uuid", + "total_quantity": 100, + "reserved_quantity": 5, + "available_quantity": 95, + "updated_at": "2026-01-01T00:00:00Z" + } +} +``` + +`available_quantity = total_quantity - reserved_quantity` + +**Errors:** `400` invalid UUID · `404` inventory not found + +--- + +### Set Inventory + +`PUT /api/inventory/:product_id` +**Admin only.** + +Sets the total stock for a product. Reserved quantity is managed automatically by the order system. + +**Request body:** +```json +{ + "total_quantity": 150 +} +``` + +| Field | Type | Required | Constraints | +|-------|------|----------|-------------| +| `total_quantity` | int | yes | min 0 | + +**Response `200`:** +```json +{ + "success": true, + "data": { + "product_id": "uuid", + "total_quantity": 150, + "reserved_quantity": 5, + "available_quantity": 145, + "updated_at": "2026-01-01T00:00:00Z" + } +} +``` + +**Errors:** `400` · `401` · `403` · `404` + +--- + +## Health + +### Gateway Health + +`GET /api/health` + +No auth required. + +**Response `200`:** +```json +{ + "status": "healthy", + "service": "auron-api" +} +``` + +--- + +## Error Codes + +| HTTP Status | Meaning | +|-------------|---------| +| `400` | Bad request — invalid body or query parameters | +| `401` | Unauthenticated — missing or invalid token | +| `403` | Forbidden — authenticated but insufficient permissions | +| `404` | Resource not found | +| `409` | Conflict — duplicate resource (email, slug) or state conflict (order not cancellable) | +| `422` | Unprocessable — business rule violation (e.g. inactive product) | +| `500` | Internal server error | + +--- + +## Authentication Header + +Protected endpoints require: +``` +Authorization: Bearer +``` + +The gateway validates the JWT (HS256) and injects `X-User-ID` and `X-User-Role` headers before forwarding to downstream services. + +--- + +## Service Ports (direct access, bypass gateway) + +| Service | Port | +|---------|------| +| API Gateway | `8080` | +| User Service | `8081` | +| Product Service | `8082` | +| Order Service | `8083` | +| Payment Service | `8084` | +| Inventory Service | `8085` | +| Notification Service | `8086` | diff --git a/Auron.postman_collection.json b/Auron.postman_collection.json new file mode 100644 index 0000000..46fe848 --- /dev/null +++ b/Auron.postman_collection.json @@ -0,0 +1,809 @@ +{ + "info": { + "name": "Auron API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Auron e-commerce microservices API. Import this file into Postman, then set the `base_url` collection variable to your gateway address (default: http://localhost:8080).\n\nThe Login request automatically saves `access_token` and `refresh_token` to collection variables via a test script." + }, + "variable": [ + { + "key": "base_url", + "value": "http://localhost:8080", + "type": "string" + }, + { + "key": "access_token", + "value": "", + "type": "string" + }, + { + "key": "refresh_token", + "value": "", + "type": "string" + }, + { + "key": "user_id", + "value": "", + "type": "string" + }, + { + "key": "product_id", + "value": "", + "type": "string" + }, + { + "key": "category_id", + "value": "", + "type": "string" + }, + { + "key": "cart_item_id", + "value": "", + "type": "string" + }, + { + "key": "order_id", + "value": "", + "type": "string" + }, + { + "key": "payment_id", + "value": "", + "type": "string" + }, + { + "key": "address_id", + "value": "", + "type": "string" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "item": [ + { + "name": "Authentication", + "item": [ + { + "name": "Register", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/auth/register", + "host": ["{{base_url}}"], + "path": ["api", "auth", "register"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"user@example.com\",\n \"password\": \"securepass123\",\n \"confirm_password\": \"securepass123\",\n \"name\": \"Jane Doe\"\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Register a new customer account. All registrations default to `customer` role regardless of the `role` field." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 201) {", + " const body = pm.response.json();", + " if (body.data && body.data.id) {", + " pm.collectionVariables.set('user_id', body.data.id);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Login", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/auth/login", + "host": ["{{base_url}}"], + "path": ["api", "auth", "login"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"user@example.com\",\n \"password\": \"securepass123\"\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Login and receive JWT tokens. The test script automatically saves `access_token` and `refresh_token` to collection variables." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const body = pm.response.json();", + " if (body.access_token) {", + " pm.collectionVariables.set('access_token', body.access_token);", + " }", + " if (body.refresh_token) {", + " pm.collectionVariables.set('refresh_token', body.refresh_token);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Refresh Token", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/auth/refresh", + "host": ["{{base_url}}"], + "path": ["api", "auth", "refresh"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"refresh_token\": \"{{refresh_token}}\"\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Exchange a refresh token for a new access token. The test script updates the collection variables." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const body = pm.response.json();", + " if (body.access_token) {", + " pm.collectionVariables.set('access_token', body.access_token);", + " }", + " if (body.refresh_token) {", + " pm.collectionVariables.set('refresh_token', body.refresh_token);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Logout", + "request": { + "auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "{{access_token}}", "type": "string" }] }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/auth/logout", + "host": ["{{base_url}}"], + "path": ["api", "auth", "logout"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"refresh_token\": \"{{refresh_token}}\"\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Revoke the refresh token. Clears auth cookies." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " pm.collectionVariables.set('access_token', '');", + " pm.collectionVariables.set('refresh_token', '');", + "}" + ], + "type": "text/javascript" + } + } + ] + } + ] + }, + { + "name": "Users", + "item": [ + { + "name": "Get Profile", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/users/me", + "host": ["{{base_url}}"], + "path": ["api", "users", "me"] + }, + "description": "Get the authenticated user's profile." + } + }, + { + "name": "Update Profile", + "request": { + "method": "PUT", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/users/me", + "host": ["{{base_url}}"], + "path": ["api", "users", "me"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Jane Smith\"\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Update profile. All fields are optional." + } + }, + { + "name": "Add Address", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/users/me/addresses", + "host": ["{{base_url}}"], + "path": ["api", "users", "me", "addresses"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"Home\",\n \"street\": \"123 Main St\",\n \"city\": \"Jakarta\",\n \"state\": \"DKI Jakarta\",\n \"country\": \"Indonesia\",\n \"postal_code\": \"12345\",\n \"is_default\": true\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Add a shipping address for the authenticated user." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 201) {", + " const body = pm.response.json();", + " if (body.data && body.data.id) {", + " pm.collectionVariables.set('address_id', body.data.id);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "List Addresses", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/users/me/addresses", + "host": ["{{base_url}}"], + "path": ["api", "users", "me", "addresses"] + }, + "description": "List all shipping addresses for the authenticated user." + } + }, + { + "name": "Update Address", + "request": { + "method": "PUT", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/users/me/addresses/{{address_id}}", + "host": ["{{base_url}}"], + "path": ["api", "users", "me", "addresses", "{{address_id}}"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"Office\",\n \"is_default\": false\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Update an address. All fields are optional." + } + }, + { + "name": "Delete Address", + "request": { + "method": "DELETE", + "url": { + "raw": "{{base_url}}/api/users/me/addresses/{{address_id}}", + "host": ["{{base_url}}"], + "path": ["api", "users", "me", "addresses", "{{address_id}}"] + }, + "description": "Delete an address by ID." + } + } + ] + }, + { + "name": "Products", + "item": [ + { + "name": "List Products", + "request": { + "auth": { "type": "noauth" }, + "method": "GET", + "url": { + "raw": "{{base_url}}/api/products?page=1&limit=20", + "host": ["{{base_url}}"], + "path": ["api", "products"], + "query": [ + { "key": "q", "value": "", "description": "Full-text search", "disabled": true }, + { "key": "category_id", "value": "{{category_id}}", "description": "Filter by category UUID", "disabled": true }, + { "key": "min_price", "value": "10", "description": "Minimum price", "disabled": true }, + { "key": "max_price", "value": "500", "description": "Maximum price", "disabled": true }, + { "key": "sort", "value": "newest", "description": "price_asc | price_desc | newest | name_asc | name_desc", "disabled": true }, + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + }, + "description": "List products. Public endpoint. Supports full-text search, category/price filters, sorting, and pagination." + } + }, + { + "name": "Get Product", + "request": { + "auth": { "type": "noauth" }, + "method": "GET", + "url": { + "raw": "{{base_url}}/api/products/{{product_id}}", + "host": ["{{base_url}}"], + "path": ["api", "products", "{{product_id}}"] + }, + "description": "Get a single product by UUID. Public endpoint." + } + }, + { + "name": "Create Product (Admin)", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/products", + "host": ["{{base_url}}"], + "path": ["api", "products"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"category_id\": \"{{category_id}}\",\n \"name\": \"Wireless Headphones\",\n \"description\": \"Premium noise-cancelling wireless headphones.\",\n \"price\": 149.99,\n \"image_url\": \"https://example.com/headphones.jpg\",\n \"is_active\": true\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Create a product. Requires admin role." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 201) {", + " const body = pm.response.json();", + " if (body.data && body.data.id) {", + " pm.collectionVariables.set('product_id', body.data.id);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Update Product (Admin)", + "request": { + "method": "PUT", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/products/{{product_id}}", + "host": ["{{base_url}}"], + "path": ["api", "products", "{{product_id}}"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"category_id\": \"{{category_id}}\",\n \"name\": \"Wireless Headphones Pro\",\n \"description\": \"Updated description.\",\n \"price\": 179.99,\n \"is_active\": true\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Update a product. Requires admin role." + } + }, + { + "name": "Delete Product (Admin)", + "request": { + "method": "DELETE", + "url": { + "raw": "{{base_url}}/api/products/{{product_id}}", + "host": ["{{base_url}}"], + "path": ["api", "products", "{{product_id}}"] + }, + "description": "Delete a product. Requires admin role." + } + } + ] + }, + { + "name": "Categories", + "item": [ + { + "name": "List Categories", + "request": { + "auth": { "type": "noauth" }, + "method": "GET", + "url": { + "raw": "{{base_url}}/api/categories", + "host": ["{{base_url}}"], + "path": ["api", "categories"] + }, + "description": "List all product categories. Public endpoint." + } + }, + { + "name": "Create Category (Admin)", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/categories", + "host": ["{{base_url}}"], + "path": ["api", "categories"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Electronics\",\n \"slug\": \"electronics\"\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Create a category. Requires admin role. `slug` must be unique." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 201) {", + " const body = pm.response.json();", + " if (body.data && body.data.id) {", + " pm.collectionVariables.set('category_id', body.data.id);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ] + } + ] + }, + { + "name": "Cart", + "item": [ + { + "name": "Get Cart", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/cart", + "host": ["{{base_url}}"], + "path": ["api", "cart"] + }, + "description": "Get the authenticated user's cart. Cart is created automatically on first access." + } + }, + { + "name": "Add Item", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/cart/items", + "host": ["{{base_url}}"], + "path": ["api", "cart", "items"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"product_id\": \"{{product_id}}\",\n \"quantity\": 1\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Add a product to the cart. Increments quantity if product already exists in cart." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const body = pm.response.json();", + " if (body.data && body.data.items && body.data.items.length > 0) {", + " pm.collectionVariables.set('cart_item_id', body.data.items[0].id);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Update Item", + "request": { + "method": "PUT", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/cart/items/{{cart_item_id}}", + "host": ["{{base_url}}"], + "path": ["api", "cart", "items", "{{cart_item_id}}"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"quantity\": 3\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Update the quantity of a cart item. `:id` is the cart item UUID." + } + }, + { + "name": "Remove Item", + "request": { + "method": "DELETE", + "url": { + "raw": "{{base_url}}/api/cart/items/{{cart_item_id}}", + "host": ["{{base_url}}"], + "path": ["api", "cart", "items", "{{cart_item_id}}"] + }, + "description": "Remove a specific item from the cart." + } + } + ] + }, + { + "name": "Orders", + "item": [ + { + "name": "List Orders", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/orders?page=1&limit=10", + "host": ["{{base_url}}"], + "path": ["api", "orders"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "limit", "value": "10" } + ] + }, + "description": "List orders for the authenticated user, newest first." + } + }, + { + "name": "Create Order", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/orders", + "host": ["{{base_url}}"], + "path": ["api", "orders"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"shipping_name\": \"Jane Doe\",\n \"shipping_address\": \"123 Main St, Jakarta 12345\"\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Convert the current cart into an order. Reserves inventory, clears cart, and triggers payment creation via Kafka. Use GET /payments/order/:order_id to retrieve the Stripe client_secret." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 201) {", + " const body = pm.response.json();", + " if (body.data && body.data.id) {", + " pm.collectionVariables.set('order_id', body.data.id);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Get Order", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/orders/{{order_id}}", + "host": ["{{base_url}}"], + "path": ["api", "orders", "{{order_id}}"] + }, + "description": "Get a single order by UUID. Returns 403 if the order does not belong to the authenticated user." + } + }, + { + "name": "Cancel Order", + "request": { + "method": "PUT", + "url": { + "raw": "{{base_url}}/api/orders/{{order_id}}/cancel", + "host": ["{{base_url}}"], + "path": ["api", "orders", "{{order_id}}", "cancel"] + }, + "description": "Cancel an order. Only orders with status `pending`, `confirmed`, or `processing` can be cancelled. Releases reserved inventory." + } + } + ] + }, + { + "name": "Payments", + "item": [ + { + "name": "Get Payment", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/payments/{{payment_id}}", + "host": ["{{base_url}}"], + "path": ["api", "payments", "{{payment_id}}"] + }, + "description": "Get a payment by payment UUID. Returns 403 if it does not belong to the authenticated user." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const body = pm.response.json();", + " if (body.data && body.data.id) {", + " pm.collectionVariables.set('payment_id', body.data.id);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Get Payment by Order", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/payments/order/{{order_id}}", + "host": ["{{base_url}}"], + "path": ["api", "payments", "order", "{{order_id}}"] + }, + "description": "Get the payment for an order. Includes `client_secret` for Stripe.js payment confirmation on the frontend. Call this after POST /orders to get the client_secret." + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const body = pm.response.json();", + " if (body.data && body.data.id) {", + " pm.collectionVariables.set('payment_id', body.data.id);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Stripe Webhook (internal)", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Stripe-Signature", "value": "t=...,v1=...", "description": "Generated by Stripe CLI or Stripe dashboard" } + ], + "url": { + "raw": "{{base_url}}/api/payments/webhook/stripe", + "host": ["{{base_url}}"], + "path": ["api", "payments", "webhook", "stripe"] + }, + "body": { + "mode": "raw", + "raw": "{}", + "options": { "raw": { "language": "json" } } + }, + "description": "Internal Stripe webhook endpoint. Do not call manually — this is for Stripe event delivery only. The Stripe-Signature header is required and validated using STRIPE_WEBHOOK_SECRET." + } + } + ] + }, + { + "name": "Inventory", + "item": [ + { + "name": "Get Inventory", + "request": { + "auth": { "type": "noauth" }, + "method": "GET", + "url": { + "raw": "{{base_url}}/api/inventory/{{product_id}}", + "host": ["{{base_url}}"], + "path": ["api", "inventory", "{{product_id}}"] + }, + "description": "Get stock levels for a product. Public endpoint. Returns total, reserved, and available quantities." + } + }, + { + "name": "Set Inventory (Admin)", + "request": { + "method": "PUT", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{base_url}}/api/inventory/{{product_id}}", + "host": ["{{base_url}}"], + "path": ["api", "inventory", "{{product_id}}"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"total_quantity\": 100\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Set total stock for a product. Requires admin role. Reserved quantity is managed automatically by the order system." + } + } + ] + }, + { + "name": "Health", + "item": [ + { + "name": "Gateway Health", + "request": { + "auth": { "type": "noauth" }, + "method": "GET", + "url": { + "raw": "{{base_url}}/api/health", + "host": ["{{base_url}}"], + "path": ["api", "health"] + }, + "description": "Check if the API gateway is up." + } + } + ] + } + ] +} From 6a7babbff278693d076d3186010002c353dd3964 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 17:55:48 +0700 Subject: [PATCH 12/15] docs: Add comprehensive project README Covers architecture diagram, full tech stack, all 7 services and their responsibilities, Kafka event flow, getting started steps (env setup, Stripe webhook forwarding, admin promotion), Make command reference, payment checkout flow, API quick reference, project directory layout, and environment variable table. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 331 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..92b9d13 --- /dev/null +++ b/README.md @@ -0,0 +1,331 @@ +# Auron + +A production-grade e-commerce platform built with a Go microservices backend and a Next.js frontend. Each service owns its data, communicates asynchronously over Kafka, and is independently deployable via Docker. + +--- + +## Architecture + +``` + ┌─────────────────┐ + │ Next.js │ + │ Frontend │ + │ :3000 │ + └────────┬────────┘ + │ HTTP + ▼ + ┌─────────────────┐ + │ API Gateway │ JWT auth · rate limiting + │ :8080 │ reverse proxy + └────────┬────────┘ + │ + ┌──────────┬───────────┼───────────┬──────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ User │ │ Product │ │ Order │ │ Payment │ │Inventory │ │Notif. │ + │ Service │ │ Service │ │ Service │ │ Service │ │ Service │ │ Service │ + │ :8081 │ │ :8082 │ │ :8083 │ │ :8084 │ │ :8085 │ │ :8086 │ + └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ │ │ + │ ┌─────┴─────┐ │ │ ┌──────┴──────┐ │ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ │ + users-db products-db orders-db payments-db products-db │ + │ + ┌──────────────────────────────┐ │ + │ Kafka │◄─────────────────┘ + │ (async inter-service events) │ + └──────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + Redis PostgreSQL Stripe + (caching) (per-service) (payments) +``` + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Backend | Go 1.25 · Gin · GORM | +| Frontend | Next.js 16 · React 19 · Tailwind CSS 4 · TypeScript | +| Databases | PostgreSQL 15 (one per service) | +| Cache | Redis 7 | +| Message broker | Apache Kafka (Confluent Platform 7.6) | +| Payments | Stripe (stripe-go v76) | +| Auth | JWT HS256 (shared secret across services) | +| Containerisation | Docker · Docker Compose | +| Observability | Prometheus · Grafana | + +--- + +## Services + +| Service | Port | Responsibility | +|---------|------|----------------| +| **api-gateway** | 8080 | JWT validation, rate limiting, reverse proxy to all downstream services | +| **user-service** | 8081 | Registration, login, JWT issuance, profile, addresses | +| **product-service** | 8082 | Product catalogue, categories, PostgreSQL full-text search, Redis cache | +| **order-service** | 8083 | Cart management, order creation, inventory reservation | +| **payment-service** | 8084 | Stripe PaymentIntent lifecycle, webhook handling, payment status | +| **inventory-service** | 8085 | Stock levels, reservation/release on order events | +| **notification-service** | 8086 | Email delivery via SMTP (stateless Kafka consumer, no database) | + +### Supporting infrastructure + +| Service | Port | Purpose | +|---------|------|---------| +| users-db | 5432 | PostgreSQL for user-service | +| products-db | 5433 | PostgreSQL for product-service and inventory-service | +| orders-db | 5434 | PostgreSQL for order-service | +| payments-db | 5435 | PostgreSQL for payment-service | +| Redis | 6380 | Shared cache (token deny-list, product/payment caching) | +| Kafka | 9092 | External listener (services use internal port 29092) | +| Kafka UI | 8090 | Web UI for browsing topics and messages | +| Prometheus | 9090 | Metrics scraping | +| Grafana | 3001 | Dashboards (admin / admin) | + +--- + +## Kafka Event Flow + +``` +user-service ──► user.created +order-service ──► order.created ──► payment-service (create PaymentIntent) + ──► inventory-service (reserve stock) +order-service ──► order.cancelled ──► inventory-service (release stock) +payment-service ──► payment.created +payment-service ──► payment.completed ──► notification-service +payment-service ──► payment.failed ──► notification-service +inventory-service ──► inventory.low_stock ──► notification-service +``` + +All topics use the prefix convention `.` and 6 partitions by default. + +--- + +## Getting Started + +### Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) and Docker Compose v2 +- A [Stripe](https://dashboard.stripe.com/register) account (for payment testing) +- [Stripe CLI](https://stripe.com/docs/stripe-cli) (optional, for local webhook forwarding) + +### 1. Clone and configure environment + +```bash +git clone https://github.com/rezadrian01/auron.git +cd auron +cp .env.example .env +``` + +Open `.env` and fill in the required values: + +```env +# Required +JWT_SECRET=your-32-char-minimum-secret-here +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Optional — SMTP for notification emails +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_FROM=noreply@auron.shop +``` + +> **JWT_SECRET** must be the same value used by all services. It is shared via the root `.env` file and injected by Docker Compose into the gateway and each service. + +### 2. Start the stack + +```bash +make up +``` + +This builds all Docker images and starts every service. On first boot, each service runs `AutoMigrate` to create its database schema. + +Wait ~30 seconds for Kafka and all databases to report healthy, then verify: + +```bash +make health +``` + +### 3. (Optional) Set up Stripe webhook forwarding for local testing + +```bash +stripe listen --forward-to http://localhost:8080/api/payments/webhook/stripe +``` + +Copy the `whsec_...` secret printed by the CLI and set it as `STRIPE_WEBHOOK_SECRET` in your `.env`, then restart the payment-service: + +```bash +docker compose restart payment-service +``` + +### 4. Create an admin account + +All registrations default to the `customer` role. To promote a user to admin: + +```bash +docker compose exec users-db psql -U auron -d users_db \ + -c "UPDATE users SET role='admin' WHERE email='your@email.com';" +``` + +--- + +## Make Commands + +``` +make up Build images and start the full stack +make down Stop and remove all containers +make restart down + up +make infra-up Start only databases, Kafka, Redis, and observability tools +make build Compile all Go services locally +make build-docker Build all Docker images without starting containers +make test Run go test ./... across all services +make logs Tail logs from all containers +make logs-svc SERVICE=order-service Tail logs from one service +make ps Show running containers +make health Check HTTP health endpoints for all services +make kafka-topics Create all Kafka topics manually +make clean Remove all containers and volumes (destructive) +make deps Download Go module dependencies for all services +make tidy Run go mod tidy across all services +``` + +--- + +## Payment Flow + +The checkout flow works as follows: + +``` +1. Add items to cart POST /api/cart/items +2. Create order POST /api/orders + └─► Kafka: order.created + └─► payment-service creates Stripe PaymentIntent +3. Fetch client_secret GET /api/payments/order/:order_id +4. Confirm payment Stripe.js confirmCardPayment(client_secret) + └─► Stripe webhook: payment_intent.succeeded + └─► payment-service sets status = completed + └─► Kafka: payment.completed +5. Poll payment status GET /api/payments/:payment_id +``` + +> There is a short async delay between step 2 and when the `client_secret` is available (payment-service must process the Kafka event and call the Stripe API). Polling `GET /api/payments/order/:order_id` until `status` is no longer `pending` is the recommended pattern. + +--- + +## API Reference + +Full endpoint documentation is in [API_DOCS.md](./API_DOCS.md). + +A ready-to-import Postman collection is at [Auron.postman_collection.json](./Auron.postman_collection.json). It includes: +- Collection-level Bearer auth using `{{access_token}}` +- Test scripts that auto-save tokens, IDs, and UUIDs after each request +- All 30 endpoints organised into folders + +### Quick reference + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/auth/register` | — | Register | +| POST | `/api/auth/login` | — | Login | +| POST | `/api/auth/refresh` | — | Refresh token | +| POST | `/api/auth/logout` | ✓ | Logout | +| GET | `/api/users/me` | ✓ | Get profile | +| PUT | `/api/users/me` | ✓ | Update profile | +| GET/POST | `/api/users/me/addresses` | ✓ | Addresses | +| GET | `/api/products` | — | List products (search, filter, sort) | +| GET | `/api/products/:id` | — | Get product | +| POST/PUT/DELETE | `/api/products` | admin | Manage products | +| GET | `/api/categories` | — | List categories | +| POST | `/api/categories` | admin | Create category | +| GET | `/api/cart` | ✓ | Get cart | +| POST | `/api/cart/items` | ✓ | Add item | +| PUT/DELETE | `/api/cart/items/:id` | ✓ | Update/remove item | +| GET/POST | `/api/orders` | ✓ | List / create order | +| GET | `/api/orders/:id` | ✓ | Get order | +| PUT | `/api/orders/:id/cancel` | ✓ | Cancel order | +| GET | `/api/payments/:id` | ✓ | Get payment | +| GET | `/api/payments/order/:id` | ✓ | Get payment by order (includes `client_secret`) | +| POST | `/api/payments/webhook/stripe` | — | Stripe webhook | +| GET | `/api/inventory/:product_id` | — | Get stock | +| PUT | `/api/inventory/:product_id` | admin | Set stock | +| GET | `/api/health` | — | Gateway health | + +--- + +## Project Structure + +``` +auron/ +├── docker-compose.yml # Full stack definition +├── Makefile # Developer commands +├── .env.example # Environment variable template +├── API_DOCS.md # Full API reference +├── Auron.postman_collection.json +│ +├── services/ +│ ├── api-gateway/ # Gin · JWT middleware · httputil.ReverseProxy +│ ├── user-service/ # Gin · GORM · Redis · Kafka producer +│ ├── product-service/ # Gin · GORM · Redis · full-text search +│ ├── order-service/ # Gin · GORM · Redis · Kafka producer +│ ├── payment-service/ # Gin · GORM · Redis · stripe-go · Kafka +│ ├── inventory-service/ # Gin · GORM · Redis · Kafka consumer+producer +│ └── notification-service/ # Gin (health only) · net/smtp · Kafka consumer +│ +├── shared/ # Shared Go modules (Kafka helpers, Redis client) +│ +├── frontend/ # Next.js 16 · React 19 · Tailwind CSS 4 +│ ├── app/ # App Router pages and layouts +│ └── components/ # Cart, checkout, product, UI components +│ +└── infra/ + ├── kafka/topics.sh # Topic creation script + ├── postgres/ # Database init SQL + ├── prometheus/ # Scrape config + └── grafana/ # Dashboard definitions +``` + +Each Go service follows the same internal layout: + +``` +/ +├── main.go +├── cmd/ # Wiring: config, database, redis, kafka, HTTP server +└── internal/ + ├── domain/ # Entities, DTOs, repository and service interfaces + ├── handler/ # HTTP handlers (Gin) + ├── route/ # Route registration + ├── service/ # Business logic + ├── repository/# GORM implementations + ├── cache/ # Redis implementations + └── events/ # Kafka producers / consumers +``` + +--- + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `JWT_SECRET` | yes | HS256 signing secret, shared across all services | +| `STRIPE_SECRET_KEY` | yes | Stripe secret key (`sk_test_...` or `sk_live_...`) | +| `STRIPE_WEBHOOK_SECRET` | yes | Stripe webhook signing secret (`whsec_...`) | +| `SMTP_HOST` | no | SMTP server hostname (defaults to no-op logging) | +| `SMTP_PORT` | no | SMTP port (default `587`) | +| `SMTP_FROM` | no | From address for notification emails | +| `SMTP_USER` | no | SMTP username (omit for unauthenticated relay, e.g. MailHog) | +| `SMTP_PASS` | no | SMTP password | +| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | no | Stripe publishable key for frontend Stripe.js | + +Database URLs and internal service URLs are pre-configured in `docker-compose.yml` and do not need to be set in `.env`. + +--- + +## License + +MIT — see [LICENSE](./LICENSE). +Copyright © 2026 Ahmad Reza Adrian. From 7f0b374f8c54df1ccccb6af70339881a774e260c Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 17:57:21 +0700 Subject: [PATCH 13/15] fix: Correct JWT env vars in .env.example Replaced JWT_PRIVATE_KEY / JWT_PUBLIC_KEY (leftover RSA design) with JWT_SECRET and JWT_REFRESH_SECRET, which is what the services actually read. Updated the comment to reflect the HS256 shared-secret approach. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 676a873..0132116 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,11 @@ # ============================================================ # JWT CONFIGURATION # ============================================================ -# Generate with: openssl genrsa -out jwt-private.pem 2048 -# Generate public key with: openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem -JWT_PRIVATE_KEY= -JWT_PUBLIC_KEY= +# Shared HMAC secret — must be identical in user-service and api-gateway +# Generate with: openssl rand -hex 32 +JWT_SECRET=your-strong-secret-min-32-chars-here +# Optional: separate secret for refresh tokens. Falls back to JWT_SECRET if unset. +JWT_REFRESH_SECRET=your-refresh-token-secret-here JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=168h From 3ad6c9bb3dfb6b01a291bb9a21076062baf02c3f Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 17:59:05 +0700 Subject: [PATCH 14/15] fix: Remove FIXES_PLAN.md as it is no longer needed --- FIXES_PLAN.md | 450 -------------------------------------------------- 1 file changed, 450 deletions(-) delete mode 100644 FIXES_PLAN.md diff --git a/FIXES_PLAN.md b/FIXES_PLAN.md deleted file mode 100644 index 20d0407..0000000 --- a/FIXES_PLAN.md +++ /dev/null @@ -1,450 +0,0 @@ -# Auron — Fixes & Product Service Completion Plan - -> **Scope:** JWT algorithm fix · Gateway auth wiring · Product service layer · Product service entry point · Cache key nil panic -> **Branch:** `feature/product-service` -> **Order:** Tasks must be done in sequence — each builds on the previous. - ---- - -## Table of Contents - -1. [Task 1 — Fix JWT Algorithm Mismatch](#task-1--fix-jwt-algorithm-mismatch) -2. [Task 2 — Wire Auth Middleware in Gateway](#task-2--wire-auth-middleware-in-gateway) -3. [Task 3 — Implement Product Service Layer](#task-3--implement-product-service-layer) -4. [Task 4 — Add Product Service Entry Point](#task-4--add-product-service-entry-point) -5. [Task 5 — Fix GenerateCacheKey Nil Panic](#task-5--fix-generatecachekey-nil-panic) -6. [Verification Checklist](#verification-checklist) - ---- - -## Task 1 — Fix JWT Algorithm Mismatch - -### Problem - -The user service signs tokens with **HS256** but the API Gateway validates expecting **RS256 (RSA)**. Every token the user-service issues fails gateway validation — auth is completely broken end-to-end. - -| File | Algorithm | Secret Source | -|---|---|---| -| `services/user-service/internal/service/user_service.go:369` | HS256 | `JWT_SECRET` env var | -| `services/user-service/internal/middleware/auth_middleware.go:39` | HS256 | `JWT_SECRET` env var | -| `services/api-gateway/middleware/auth.go:94` | **RS256** | PEM file at `JWT_PUBLIC_KEY` path | - -**Decision: standardize on HS256.** Both user-service files already use it. RS256 is architecturally better for multi-service trust but adds operational complexity (key file management). Can be upgraded later. - -### Files to Change - -#### `services/api-gateway/middleware/auth.go` - -- Replace `type JWTMiddleware struct { publicKey *rsa.PublicKey }` with `type JWTMiddleware struct { secret []byte }` -- Replace `NewJWTMiddleware(keyPath string)` (reads PEM file) with `NewJWTMiddleware(secret string)` (takes raw string) -- In `Auth()` and `RequireAuth()`: change the `jwt.ParseWithClaims` key func to check for `*jwt.SigningMethodHMAC` and return `j.secret` -- Update the `Claims` struct: the user-service puts the user UUID in `"sub"`, not `"user_id"`. Change `UserID string json:"user_id"` → `Sub string json:"sub"` and set `c.Set(UserIDKey, claims.Sub)` inside the middleware -- Remove now-unused imports: `crypto/rsa`, `encoding/pem`, `io/ioutil` - -#### `services/api-gateway/config/config.go` - -- Replace `JWTPublicKeyPath string` (env: `JWT_PUBLIC_KEY`) with `JWTSecret string` (env: `JWT_SECRET`, no default — must be set explicitly) - -#### `services/api-gateway/main.go` - -- Replace `middleware.NewJWTMiddleware(cfg.JWTPublicKeyPath)` with `middleware.NewJWTMiddleware(cfg.JWTSecret)` - -#### `services/api-gateway/.env` and `.env.example` - -- Remove `JWT_PUBLIC_KEY=...` -- Add `JWT_SECRET=` - -#### `docker-compose.yml` - -- Add `JWT_SECRET` to the `api-gateway` environment block, sourced from the root `.env` so both containers share the identical value - ---- - -## Task 2 — Wire Auth Middleware in Gateway - -### Problem - -`JWTMiddleware.RequireAuth()` and `RequireRole()` exist but are **never called** in `services/api-gateway/routes/router.go`. All routes — including write endpoints, user profile, cart, and orders — are fully unprotected. - -### Route Protection Matrix - -| Route | Methods | Auth | Role | -|---|---|---|---| -| `/api/auth/logout` | POST | Yes | Any | -| `/api/users/*` | All | Yes | Any | -| `/api/products` | GET | No | — | -| `/api/products/:id` | GET | No | — | -| `/api/products` | POST | Yes | `admin` | -| `/api/products/:id` | PUT, DELETE | Yes | `admin` | -| `/api/categories` | GET | No | — | -| `/api/categories` | POST | Yes | `admin` | -| `/api/cart/*` | All | Yes | Any | -| `/api/orders/*` | All | Yes | Any | -| `/api/payments/:id` | GET | Yes | Any | -| `/api/payments/webhook/stripe` | POST | **No** | — (Stripe signs its own payload) | -| `/api/inventory/*` | All | Yes | `admin` | - -### Files to Change - -#### `services/api-gateway/routes/router.go` - -At the top of `Setup()`, instantiate the middleware using the secret from config (after Task 1 lands): - -```go -jwtMiddleware, err := middleware.NewJWTMiddleware(cfg.JWTSecret) -if err != nil { - return fmt.Errorf("create jwt middleware: %w", err) -} -requireAuth := jwtMiddleware.RequireAuth() -requireAdmin := gin.HandlersChain{jwtMiddleware.RequireAuth(), jwtMiddleware.RequireRole("admin")} -``` - -Then apply per group: - -- `auth` group: add `requireAuth` to the `authProtected` sub-group (logout route) -- `users` group: add `requireAuth` to the group-level `Use()` -- `products` write routes: change the three write routes to use `requireAdmin` handlers prepended -- `categories` POST: add `requireAdmin` -- `cart` group: add `requireAuth` to group-level `Use()` -- `orders` group: add `requireAuth` to group-level `Use()` -- `payments.GET("/:id")`: add `requireAuth` inline on that route only -- `inventory` group: add `requireAdmin` to group-level `Use()` - -> **Note:** `proxy.go` already forwards `X-User-ID`, `X-User-Email`, `X-User-Role` headers downstream once the context keys are set by the middleware — no changes needed there. - ---- - -## Task 3 — Implement Product Service Layer - -### Problem - -Every method in `services/product-service/internal/service/product_service.go` returns `nil, nil` — the file is entirely stubs. Additionally, the concrete method signatures don't match the `domain.ProductService` interface (wrong argument types, missing `context.Context`), so it won't compile. - -### Sub-task 3.0 — Fix Interface Signature Mismatch First - -`domain.ProductService` interface (`internal/domain/service.go`) uses `context.Context` + `uuid.UUID`: - -```go -GetProductByID(ctx context.Context, id uuid.UUID) (*Product, error) -``` - -But the concrete struct uses bare `string` with no context: - -```go -GetProductByID(id string) (*Product, error) // ← won't satisfy interface -``` - -**Fix:** Update every method signature in `product_service.go` to match `domain.ProductService` exactly — add `ctx context.Context` as first param and use `uuid.UUID` (not `string`) for IDs. - -### Sub-task 3.1 — Read Methods (cache-aside pattern) - -**`GetProductByID(ctx, id)`** -1. `cache.GetProduct(ctx, id.String())` -2. On cache miss → `repo.GetProductByID(id)` -3. `cache.SetProduct(ctx, product)` — log error, don't fail -4. Return product - -**`GetProducts(ctx, filter)`** -1. Validate filter (page ≥ 1, limit 1–100, sort in `ValidSorts`) -2. Build cache key via `GenerateCacheKey(filter)` (fixed in Task 5) -3. `cache.GetProductList(ctx, cacheKey)` -4. On cache miss → `repo.GetProducts(filter)` -5. `cache.SetProductList(ctx, cacheKey, result)` — log error, don't fail -6. Return result - -**`GetCategories(ctx)`, `GetCategoryByID(ctx, id)`, `GetCategoryBySlug(ctx, slug)`** -- Direct repo calls — no caching needed at this stage - -### Sub-task 3.2 — Write Methods (invalidate cache + publish event) - -**`CreateProduct(ctx, req)`** -1. Verify category exists: `repo.GetCategoryByID(req.CategoryID)` → `ErrCategoryNotFound` if missing -2. Build `domain.Product` from request; set `ID = uuid.New()`, timestamps -3. `repo.CreateProduct(&product)` -4. `cache.SetProduct(ctx, product)` + `cache.InvalidateProductList(ctx)` -5. `publisher.Publish(ctx, TopicProductCreated, product)` — log error, don't fail request -6. Return product - -**`UpdateProduct(ctx, id, req)`** -1. Fetch existing: `repo.GetProductByID(id)` → propagate `ErrProductNotFound` -2. If `CategoryID` changed, verify new category exists -3. Apply fields from req, update `UpdatedAt` -4. `repo.UpdateProduct(&product)` -5. `cache.SetProduct(ctx, product)` + `cache.InvalidateProductList(ctx)` -6. `publisher.Publish(ctx, TopicProductUpdated, product)` -7. Return product - -**`DeleteProduct(ctx, id)`** -1. Verify exists: `repo.GetProductByID(id)` -2. `repo.DeleteProduct(id)` -3. `cache.DeleteProduct(ctx, id.String())` + `cache.InvalidateProductList(ctx)` -4. `publisher.Publish(ctx, TopicProductDeleted, gin.H{"product_id": id})` -5. Return nil - -**`CreateCategory(ctx, req)`** -1. Check slug uniqueness: `repo.GetCategoryBySlug(req.Slug)` → if found, return `ErrCategorySlugExists` -2. Build `domain.Category`; set `ID = uuid.New()` -3. `repo.CreateCategory(&category)` -4. Return category - ---- - -## Task 4 — Add Product Service Entry Point - -### Problem - -The product service has no `main.go`, no `Dockerfile`, no config loader, no HTTP handler, no route layer. It cannot be built or run. - -### Files to Create - -``` -services/product-service/ -├── main.go -├── Dockerfile -├── .env -├── .env.example -├── cmd/ -│ ├── config.go -│ ├── dotenv.go -│ ├── infrastructure.go -│ ├── kafka.go -│ ├── run.go -│ └── server.go -└── internal/ - ├── handler/ - │ └── product_handler.go - └── route/ - └── product_route.go -``` - -### `cmd/config.go` - -```go -type Config struct { - Port string - DatabaseURL string - RedisURL string - KafkaBrokers string -} -``` - -Env vars: `PORT` (default `"8082"`), `DATABASE_URL` (required), `REDIS_URL` (default `"redis://localhost:6379/0"`), `KAFKA_BROKERS` (default `"localhost:9092"`). - -### `cmd/dotenv.go` - -Same pattern as user-service: silently skip if `.env` is absent. - -### `cmd/infrastructure.go` - -- **DB:** GORM + `gorm.io/driver/postgres`. Run `AutoMigrate(&domain.Category{}, &domain.Product{}, &domain.Inventory{})`. Also run the tsvector trigger SQL from `db/004_create_search_index.up.sql` via `db.Exec(...)` after AutoMigrate. -- **Redis:** `redis.ParseURL(cfg.RedisURL)` → `redis.NewClient(opt)` -- **Kafka:** One `kafka.Writer` per topic (`product.created`, `product.updated`, `product.deleted`) — same pattern as `services/user-service/cmd/kafka.go` - -### `cmd/run.go` - -Wire the dependency graph in order: - -``` -config → db, redis, kafka -db → NewProductRepository(db) -redis → NewProductCache(redisClient) -kafka → NewKafkaPublisher(writers) -repo + cache + publisher → NewProductService(...) -service → NewProductHandler(service) -handler → RegisterProductRoutes(router, handler) -``` - -Register graceful shutdown (close DB, Redis, Kafka writers on SIGINT/SIGTERM). - -### `cmd/server.go` - -Same pattern as user-service `cmd/server.go`: -- `gin.SetMode(gin.ReleaseMode)` -- `gin.New()` + `gin.Logger()` + `gin.Recovery()` -- `GET /health` → `{"status":"healthy","service":"product-service"}` -- `GET /metrics` → stub (Prometheus later) -- Call `route.RegisterProductRoutes(router, h)` - -### `main.go` - -```go -package main - -import "auron/product-service/cmd" - -func main() { cmd.Run() } -``` - -### `internal/handler/product_handler.go` - -One handler per endpoint. Keep handlers thin — only HTTP binding and error mapping, no business logic. - -| Handler | HTTP | Domain call | -|---|---|---| -| `GetProducts` | `GET /products` | `service.GetProducts(ctx, filter)` | -| `GetProductByID` | `GET /products/:id` | `service.GetProductByID(ctx, id)` | -| `CreateProduct` | `POST /products` | `service.CreateProduct(ctx, req)` | -| `UpdateProduct` | `PUT /products/:id` | `service.UpdateProduct(ctx, id, req)` | -| `DeleteProduct` | `DELETE /products/:id` | `service.DeleteProduct(ctx, id)` | -| `GetCategories` | `GET /categories` | `service.GetCategories(ctx)` | -| `CreateCategory` | `POST /categories` | `service.CreateCategory(ctx, req)` | - -**`GetProducts` query param parsing:** - -| Param | Type | Default | Validation | -|---|---|---|---| -| `q` | string | `""` | none | -| `category_id` | UUID string | nil | `uuid.Parse` → 400 on invalid | -| `min_price` | float64 | nil | `strconv.ParseFloat` → 400 on invalid | -| `max_price` | float64 | nil | same | -| `sort` | string | `"newest"` | validated in service layer | -| `page` | int | 1 | validated in service layer | -| `limit` | int | 20 | validated in service layer | - -**`handleServiceError` mapping:** - -| Domain Error | HTTP Status | -|---|---| -| `ErrProductNotFound`, `ErrCategoryNotFound` | 404 | -| `ErrCategorySlugExists`, `ErrProductAlreadyExists` | 409 | -| `ErrInvalidSortParam`, `ErrInvalidPageParam`, `ErrInvalidLimitParam`, `ErrPriceMustBePositive` | 400 | -| `ErrUnauthorized` | 401 | -| `ErrForbidden` | 403 | -| everything else | 500 | - -### `internal/route/product_route.go` - -```go -func RegisterProductRoutes(router *gin.Engine, h *handler.ProductHandler) { - api := router.Group("/") - api.GET("/products", h.GetProducts) - api.GET("/products/:id", h.GetProductByID) - api.POST("/products", h.CreateProduct) - api.PUT("/products/:id", h.UpdateProduct) - api.DELETE("/products/:id",h.DeleteProduct) - api.GET("/categories", h.GetCategories) - api.POST("/categories", h.CreateCategory) -} -``` - -Auth enforcement lives at the **gateway** (Task 2). The product service trusts `X-User-Role` injected by the gateway. - -### `Dockerfile` - -Multi-stage build identical to `services/user-service/Dockerfile`, changing: -- Binary output name: `/product-service` -- `EXPOSE 8082` -- `CMD ["./product-service"]` - -### `.env` / `.env.example` - -```env -PORT=8082 -DATABASE_URL=postgres://auron:auron_pass@localhost:5433/products_db?sslmode=disable -REDIS_URL=redis://localhost:6380/0 -KAFKA_BROKERS=localhost:9092 -``` - -### `go.mod` — missing dependencies to add - -``` -github.com/gin-gonic/gin -gorm.io/driver/postgres -github.com/segmentio/kafka-go -``` - -Run `go mod tidy` after editing. - ---- - -## Task 5 — Fix GenerateCacheKey Nil Panic - -### Problem - -`services/product-service/internal/cache/product_cache.go:117` unconditionally dereferences three optional pointer fields: - -```go -// Current code — panics when any filter field is nil -hash := fmt.Sprintf("%s_%s_%s_%s_%d_%d", - filter.Q, - filter.CategoryID.String(), // nil pointer panic - fmt.Sprintf("%.2f", *filter.MinPrice), // nil pointer panic - fmt.Sprintf("%.2f", *filter.MaxPrice), // nil pointer panic - filter.Page, - filter.Limit, -) -``` - -Every `GET /products` request without all three filters panics and crashes the service. - -### Fix - -Replace with nil-safe guards before formatting: - -```go -func GenerateCacheKey(filter domain.ProductFilter) string { - categoryID := "" - if filter.CategoryID != nil { - categoryID = filter.CategoryID.String() - } - - minPrice := "0.00" - if filter.MinPrice != nil { - minPrice = fmt.Sprintf("%.2f", *filter.MinPrice) - } - - maxPrice := "0.00" - if filter.MaxPrice != nil { - maxPrice = fmt.Sprintf("%.2f", *filter.MaxPrice) - } - - key := fmt.Sprintf("%s_%s_%s_%s_%d_%d", - filter.Q, categoryID, minPrice, maxPrice, - filter.Page, filter.Limit, - ) - return ProductListPrefix + ":" + key -} -``` - -The `InvalidateProductList` scan pattern `"product:list*"` still matches after adding the `:` separator — no other changes needed. - ---- - -## Verification Checklist - -### After Task 1 -- [ ] `go build ./...` passes inside `services/api-gateway` -- [ ] `NewJWTMiddleware` no longer reads any file -- [ ] `JWT_SECRET` present in `api-gateway/.env` matching `user-service/.env` -- [ ] `docker-compose.yml` passes `JWT_SECRET` to `api-gateway` - -### After Task 2 -- [ ] `POST /api/products` without token → `401` -- [ ] `GET /api/products` without token → `200` -- [ ] `POST /api/payments/webhook/stripe` without token → `200` (not 401) -- [ ] `GET /api/users/me` without token → `401` -- [ ] Login via user-service → use returned token → `GET /api/users/me` → `200` - -### After Task 3 -- [ ] `go build ./...` passes inside `services/product-service` -- [ ] `ProductService` struct satisfies `domain.ProductService` interface (compiler verifies) -- [ ] Cache miss path calls repository -- [ ] Cache hit path does NOT call repository -- [ ] Write methods publish to Kafka; if Kafka is unavailable, request still succeeds - -### After Task 4 -- [ ] `go build ./...` passes inside `services/product-service` -- [ ] `docker build -t product-service .` succeeds -- [ ] `GET /health` → `200 {"status":"healthy"}` -- [ ] `GET /products` → `200` with paginated list -- [ ] `POST /products` with valid body → `201` with created product -- [ ] `GET /products?q=laptop` → FTS results -- [ ] `GET /products?sort=invalid` → `400` -- [ ] `GET /products/:nonexistent-id` → `404` - -### After Task 5 -- [ ] `GET /products` (no filters) does not panic -- [ ] `GET /products?category_id=` does not panic -- [ ] `GET /products?min_price=10` without `max_price` does not panic -- [ ] Two requests with different filters produce different cache keys -- [ ] Two requests with identical filters produce the same cache key From 1db364e3a6d368bb028207a9c7b7a8bca519331c Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 18:12:23 +0700 Subject: [PATCH 15/15] chore: Remove unused files and reorganise docs into docs/ - Move API_DOCS.md, API_CURL_TESTS.md, Auron.postman_collection.json, and ecommerce-technical-plan.md into docs/ folder - Delete orphaned db/*.sql migration files across order-service, payment-service, and product-service (GORM AutoMigrate handles schema) - Delete stale IMPLEMENTATION_PLAN.md files from all services and FULLTEXT_SEARCH.md from product-service - Remove unused shared/ Go module (packages were defined but never imported by any service) - Update README Project Structure to reflect the new docs/ layout Co-Authored-By: Claude Sonnet 4.6 --- README.md | 152 +- API_CURL_TESTS.md => docs/API_CURL_TESTS.md | 0 API_DOCS.md => docs/API_DOCS.md | 0 .../Auron.postman_collection.json | 0 .../ecommerce-technical-plan.md | 0 .../inventory-service/IMPLEMENTATION_PLAN.md | 429 ----- .../IMPLEMENTATION_PLAN.md | 317 ---- services/order-service/IMPLEMENTATION_PLAN.md | 361 ---- .../order-service/db/001_create_carts.up.sql | 27 - .../order-service/db/002_create_orders.up.sql | 37 - .../payment-service/IMPLEMENTATION_PLAN.md | 399 ----- .../db/001_create_payments.up.sql | 21 - services/product-service/FULLTEXT_SEARCH.md | 189 -- .../product-service/IMPLEMENTATION_PLAN.md | 1567 ----------------- .../db/004_create_search_index.up.sql | 42 - shared/events/types.go | 335 ---- shared/events/user_events.go | 24 - shared/go.mod | 8 - shared/kafka/consumer.go | 321 ---- shared/kafka/producer.go | 196 --- shared/middleware/recovery.go | 46 - shared/redis/client.go | 306 ---- 22 files changed, 77 insertions(+), 4700 deletions(-) rename API_CURL_TESTS.md => docs/API_CURL_TESTS.md (100%) rename API_DOCS.md => docs/API_DOCS.md (100%) rename Auron.postman_collection.json => docs/Auron.postman_collection.json (100%) rename ecommerce-technical-plan.md => docs/ecommerce-technical-plan.md (100%) delete mode 100644 services/inventory-service/IMPLEMENTATION_PLAN.md delete mode 100644 services/notification-service/IMPLEMENTATION_PLAN.md delete mode 100644 services/order-service/IMPLEMENTATION_PLAN.md delete mode 100644 services/order-service/db/001_create_carts.up.sql delete mode 100644 services/order-service/db/002_create_orders.up.sql delete mode 100644 services/payment-service/IMPLEMENTATION_PLAN.md delete mode 100644 services/payment-service/db/001_create_payments.up.sql delete mode 100644 services/product-service/FULLTEXT_SEARCH.md delete mode 100644 services/product-service/IMPLEMENTATION_PLAN.md delete mode 100644 services/product-service/db/004_create_search_index.up.sql delete mode 100644 shared/events/types.go delete mode 100644 shared/events/user_events.go delete mode 100644 shared/go.mod delete mode 100644 shared/kafka/consumer.go delete mode 100644 shared/kafka/producer.go delete mode 100644 shared/middleware/recovery.go delete mode 100644 shared/redis/client.go diff --git a/README.md b/README.md index 92b9d13..0315a78 100644 --- a/README.md +++ b/README.md @@ -46,45 +46,45 @@ A production-grade e-commerce platform built with a Go microservices backend and ## Tech Stack -| Layer | Technology | -|-------|-----------| -| Backend | Go 1.25 · Gin · GORM | -| Frontend | Next.js 16 · React 19 · Tailwind CSS 4 · TypeScript | -| Databases | PostgreSQL 15 (one per service) | -| Cache | Redis 7 | -| Message broker | Apache Kafka (Confluent Platform 7.6) | -| Payments | Stripe (stripe-go v76) | -| Auth | JWT HS256 (shared secret across services) | -| Containerisation | Docker · Docker Compose | -| Observability | Prometheus · Grafana | +| Layer | Technology | +| ---------------- | ------------------------------------------------------ | +| Backend | Go 1.25 · Gin · GORM | +| Frontend | Next.js 16 · React 19 · Tailwind CSS 4 · TypeScript | +| Databases | PostgreSQL 15 (one per service) | +| Cache | Redis 7 | +| Message broker | Apache Kafka (Confluent Platform 7.6) | +| Payments | Stripe (stripe-go v76) | +| Auth | JWT HS256 (shared secret across services) | +| Containerisation | Docker · Docker Compose | +| Observability | Prometheus · Grafana | --- ## Services -| Service | Port | Responsibility | -|---------|------|----------------| -| **api-gateway** | 8080 | JWT validation, rate limiting, reverse proxy to all downstream services | -| **user-service** | 8081 | Registration, login, JWT issuance, profile, addresses | -| **product-service** | 8082 | Product catalogue, categories, PostgreSQL full-text search, Redis cache | -| **order-service** | 8083 | Cart management, order creation, inventory reservation | -| **payment-service** | 8084 | Stripe PaymentIntent lifecycle, webhook handling, payment status | -| **inventory-service** | 8085 | Stock levels, reservation/release on order events | -| **notification-service** | 8086 | Email delivery via SMTP (stateless Kafka consumer, no database) | +| Service | Port | Responsibility | +| ------------------------------ | ---- | ----------------------------------------------------------------------- | +| **api-gateway** | 8080 | JWT validation, rate limiting, reverse proxy to all downstream services | +| **user-service** | 8081 | Registration, login, JWT issuance, profile, addresses | +| **product-service** | 8082 | Product catalogue, categories, PostgreSQL full-text search, Redis cache | +| **order-service** | 8083 | Cart management, order creation, inventory reservation | +| **payment-service** | 8084 | Stripe PaymentIntent lifecycle, webhook handling, payment status | +| **inventory-service** | 8085 | Stock levels, reservation/release on order events | +| **notification-service** | 8086 | Email delivery via SMTP (stateless Kafka consumer, no database) | ### Supporting infrastructure -| Service | Port | Purpose | -|---------|------|---------| -| users-db | 5432 | PostgreSQL for user-service | -| products-db | 5433 | PostgreSQL for product-service and inventory-service | -| orders-db | 5434 | PostgreSQL for order-service | -| payments-db | 5435 | PostgreSQL for payment-service | -| Redis | 6380 | Shared cache (token deny-list, product/payment caching) | -| Kafka | 9092 | External listener (services use internal port 29092) | -| Kafka UI | 8090 | Web UI for browsing topics and messages | -| Prometheus | 9090 | Metrics scraping | -| Grafana | 3001 | Dashboards (admin / admin) | +| Service | Port | Purpose | +| ----------- | ---- | ------------------------------------------------------- | +| users-db | 5432 | PostgreSQL for user-service | +| products-db | 5433 | PostgreSQL for product-service and inventory-service | +| orders-db | 5434 | PostgreSQL for order-service | +| payments-db | 5435 | PostgreSQL for payment-service | +| Redis | 6380 | Shared cache (token deny-list, product/payment caching) | +| Kafka | 9092 | External listener (services use internal port 29092) | +| Kafka UI | 8090 | Web UI for browsing topics and messages | +| Prometheus | 9090 | Metrics scraping | +| Grafana | 3001 | Dashboards (admin / admin) | --- @@ -219,41 +219,42 @@ The checkout flow works as follows: ## API Reference -Full endpoint documentation is in [API_DOCS.md](./API_DOCS.md). +Full endpoint documentation is in [API_DOCS.md](./docs/API_DOCS.md). + +A ready-to-import Postman collection is at [Auron.postman_collection.json](./docs/Auron.postman_collection.json). It includes: -A ready-to-import Postman collection is at [Auron.postman_collection.json](./Auron.postman_collection.json). It includes: - Collection-level Bearer auth using `{{access_token}}` - Test scripts that auto-save tokens, IDs, and UUIDs after each request - All 30 endpoints organised into folders ### Quick reference -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| POST | `/api/auth/register` | — | Register | -| POST | `/api/auth/login` | — | Login | -| POST | `/api/auth/refresh` | — | Refresh token | -| POST | `/api/auth/logout` | ✓ | Logout | -| GET | `/api/users/me` | ✓ | Get profile | -| PUT | `/api/users/me` | ✓ | Update profile | -| GET/POST | `/api/users/me/addresses` | ✓ | Addresses | -| GET | `/api/products` | — | List products (search, filter, sort) | -| GET | `/api/products/:id` | — | Get product | -| POST/PUT/DELETE | `/api/products` | admin | Manage products | -| GET | `/api/categories` | — | List categories | -| POST | `/api/categories` | admin | Create category | -| GET | `/api/cart` | ✓ | Get cart | -| POST | `/api/cart/items` | ✓ | Add item | -| PUT/DELETE | `/api/cart/items/:id` | ✓ | Update/remove item | -| GET/POST | `/api/orders` | ✓ | List / create order | -| GET | `/api/orders/:id` | ✓ | Get order | -| PUT | `/api/orders/:id/cancel` | ✓ | Cancel order | -| GET | `/api/payments/:id` | ✓ | Get payment | -| GET | `/api/payments/order/:id` | ✓ | Get payment by order (includes `client_secret`) | -| POST | `/api/payments/webhook/stripe` | — | Stripe webhook | -| GET | `/api/inventory/:product_id` | — | Get stock | -| PUT | `/api/inventory/:product_id` | admin | Set stock | -| GET | `/api/health` | — | Gateway health | +| Method | Path | Auth | Description | +| --------------- | -------------------------------- | ----- | ------------------------------------------------- | +| POST | `/api/auth/register` | — | Register | +| POST | `/api/auth/login` | — | Login | +| POST | `/api/auth/refresh` | — | Refresh token | +| POST | `/api/auth/logout` | ✓ | Logout | +| GET | `/api/users/me` | ✓ | Get profile | +| PUT | `/api/users/me` | ✓ | Update profile | +| GET/POST | `/api/users/me/addresses` | ✓ | Addresses | +| GET | `/api/products` | — | List products (search, filter, sort) | +| GET | `/api/products/:id` | — | Get product | +| POST/PUT/DELETE | `/api/products` | admin | Manage products | +| GET | `/api/categories` | — | List categories | +| POST | `/api/categories` | admin | Create category | +| GET | `/api/cart` | ✓ | Get cart | +| POST | `/api/cart/items` | ✓ | Add item | +| PUT/DELETE | `/api/cart/items/:id` | ✓ | Update/remove item | +| GET/POST | `/api/orders` | ✓ | List / create order | +| GET | `/api/orders/:id` | ✓ | Get order | +| PUT | `/api/orders/:id/cancel` | ✓ | Cancel order | +| GET | `/api/payments/:id` | ✓ | Get payment | +| GET | `/api/payments/order/:id` | ✓ | Get payment by order (includes `client_secret`) | +| POST | `/api/payments/webhook/stripe` | — | Stripe webhook | +| GET | `/api/inventory/:product_id` | — | Get stock | +| PUT | `/api/inventory/:product_id` | admin | Set stock | +| GET | `/api/health` | — | Gateway health | --- @@ -264,8 +265,11 @@ auron/ ├── docker-compose.yml # Full stack definition ├── Makefile # Developer commands ├── .env.example # Environment variable template -├── API_DOCS.md # Full API reference -├── Auron.postman_collection.json +│ +├── docs/ +│ ├── API_DOCS.md # Full API reference +│ ├── API_CURL_TESTS.md # curl test guide with real responses +│ └── Auron.postman_collection.json │ ├── services/ │ ├── api-gateway/ # Gin · JWT middleware · httputil.ReverseProxy @@ -276,8 +280,6 @@ auron/ │ ├── inventory-service/ # Gin · GORM · Redis · Kafka consumer+producer │ └── notification-service/ # Gin (health only) · net/smtp · Kafka consumer │ -├── shared/ # Shared Go modules (Kafka helpers, Redis client) -│ ├── frontend/ # Next.js 16 · React 19 · Tailwind CSS 4 │ ├── app/ # App Router pages and layouts │ └── components/ # Cart, checkout, product, UI components @@ -309,17 +311,17 @@ Each Go service follows the same internal layout: ## Environment Variables -| Variable | Required | Description | -|----------|----------|-------------| -| `JWT_SECRET` | yes | HS256 signing secret, shared across all services | -| `STRIPE_SECRET_KEY` | yes | Stripe secret key (`sk_test_...` or `sk_live_...`) | -| `STRIPE_WEBHOOK_SECRET` | yes | Stripe webhook signing secret (`whsec_...`) | -| `SMTP_HOST` | no | SMTP server hostname (defaults to no-op logging) | -| `SMTP_PORT` | no | SMTP port (default `587`) | -| `SMTP_FROM` | no | From address for notification emails | -| `SMTP_USER` | no | SMTP username (omit for unauthenticated relay, e.g. MailHog) | -| `SMTP_PASS` | no | SMTP password | -| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | no | Stripe publishable key for frontend Stripe.js | +| Variable | Required | Description | +| -------------------------------------- | -------- | ------------------------------------------------------------ | +| `JWT_SECRET` | yes | HS256 signing secret, shared across all services | +| `STRIPE_SECRET_KEY` | yes | Stripe secret key (`sk_test_...` or `sk_live_...`) | +| `STRIPE_WEBHOOK_SECRET` | yes | Stripe webhook signing secret (`whsec_...`) | +| `SMTP_HOST` | no | SMTP server hostname (defaults to no-op logging) | +| `SMTP_PORT` | no | SMTP port (default `587`) | +| `SMTP_FROM` | no | From address for notification emails | +| `SMTP_USER` | no | SMTP username (omit for unauthenticated relay, e.g. MailHog) | +| `SMTP_PASS` | no | SMTP password | +| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | no | Stripe publishable key for frontend Stripe.js | Database URLs and internal service URLs are pre-configured in `docker-compose.yml` and do not need to be set in `.env`. @@ -327,5 +329,5 @@ Database URLs and internal service URLs are pre-configured in `docker-compose.ym ## License -MIT — see [LICENSE](./LICENSE). +MIT — see [LICENSE](./LICENSE). Copyright © 2026 Ahmad Reza Adrian. diff --git a/API_CURL_TESTS.md b/docs/API_CURL_TESTS.md similarity index 100% rename from API_CURL_TESTS.md rename to docs/API_CURL_TESTS.md diff --git a/API_DOCS.md b/docs/API_DOCS.md similarity index 100% rename from API_DOCS.md rename to docs/API_DOCS.md diff --git a/Auron.postman_collection.json b/docs/Auron.postman_collection.json similarity index 100% rename from Auron.postman_collection.json rename to docs/Auron.postman_collection.json diff --git a/ecommerce-technical-plan.md b/docs/ecommerce-technical-plan.md similarity index 100% rename from ecommerce-technical-plan.md rename to docs/ecommerce-technical-plan.md diff --git a/services/inventory-service/IMPLEMENTATION_PLAN.md b/services/inventory-service/IMPLEMENTATION_PLAN.md deleted file mode 100644 index e9d9389..0000000 --- a/services/inventory-service/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,429 +0,0 @@ -# Inventory Service — Implementation Plan - -## Overview - -The inventory service (port **8085**) manages stock levels for products. -It is a **Kafka consumer + HTTP API** hybrid service: - -- Exposes admin-only HTTP endpoints for viewing and manually setting stock -- Consumes `order.created` → reserves stock (`ReservedQuantity += n`) -- Consumes `order.cancelled` → releases reservation (`ReservedQuantity -= n`) -- Publishes `inventory.updated` on every stock change -- Publishes `inventory.low_stock` when available stock drops below threshold - -### Gateway Routes (already wired, admin-only) - -| Method | Path | Auth | Purpose | -|---|---|---|---| -| `GET` | `/api/inventory/:product_id` | Admin | Get stock levels for a product | -| `PUT` | `/api/inventory/:product_id` | Admin | Set total stock (restocking) | - -### Database - -Inventory-service **shares `products_db`** with product-service. It does **not** create the `inventory` table — product-service's AutoMigrate owns it. Inventory-service connects to the same DB and operates directly on the `inventory` table. - -The table structure (from product-service): -``` -inventory -├── product_id UUID PRIMARY KEY -├── total_quantity INT NOT NULL DEFAULT 0 -├── reserved_quantity INT NOT NULL DEFAULT 0 -├── version INT NOT NULL DEFAULT 0 ← optimistic locking -└── updated_at TIMESTAMP NOT NULL DEFAULT NOW() -``` - -`AvailableQuantity = TotalQuantity - ReservedQuantity` - -### Stock Reservation Flow - -``` -order-service → publishes order.created - ↓ -inventory-service consumes order.created - ↓ -Increments ReservedQuantity for each item (optimistic lock on version) - ↓ -Publishes inventory.updated (and inventory.low_stock if available stock < threshold) - -If order is cancelled: -order-service → publishes order.cancelled - ↓ -inventory-service consumes order.cancelled - ↓ -Decrements ReservedQuantity for each item - ↓ -Publishes inventory.updated -``` - -> **Why not deduct TotalQuantity on order.created?** -> Reserved stock stays visible to admins as "committed but not shipped." Total stock only changes when an admin performs a restock (`PUT /inventory/:product_id`). This gives accurate available-stock visibility without needing inter-service HTTP calls. - ---- - -## Folder Structure - -``` -services/inventory-service/ -├── cmd/ -│ ├── config.go # env vars → appConfig -│ ├── dotenv.go # load .env in non-production -│ ├── infrastructure.go # setupDatabase, setupRedis (no AutoMigrate) -│ ├── kafka.go # setupKafkaPublisher, setupKafkaConsumer, startKafkaConsumer -│ ├── run.go # wire everything together -│ └── server.go # setupRouter, registerGracefulShutdown -├── db/ -│ └── NOTE.md # explains that product-service owns the inventory table -├── internal/ -│ ├── cache/ -│ │ └── inventory_cache.go -│ ├── domain/ -│ │ ├── inventory.go # Inventory entity, DTOs, event structs -│ │ ├── errors.go # sentinel errors -│ │ ├── repository.go # InventoryRepository interface -│ │ ├── service.go # InventoryService interface -│ │ ├── cache.go # InventoryCache interface -│ │ └── events.go # EventPublisher + topic constants -│ ├── events/ -│ │ ├── kafka_publisher.go -│ │ └── kafka_consumer.go # multi-topic consumer (2 topics) -│ ├── handler/ -│ │ └── inventory_handler.go -│ ├── repository/ -│ │ └── inventory_repository.go -│ ├── route/ -│ │ └── inventory_route.go -│ └── service/ -│ └── inventory_service.go -├── main.go -├── Dockerfile -├── go.mod -├── .env -└── .env.example -``` - -> `internal/middleware/` exists in the scaffold but is not used — the gateway enforces admin auth via `X-User-Role` header before proxying. - ---- - -## Tasks - -### Task 1 — Domain Layer - -**`internal/domain/inventory.go`** - -```go -type Inventory struct { - ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;primaryKey"` - TotalQuantity int `json:"total_quantity" gorm:"not null;default:0"` - ReservedQuantity int `json:"reserved_quantity" gorm:"not null;default:0"` - Version int `json:"version" gorm:"not null;default:0"` - UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` -} - -func (Inventory) TableName() string { return "inventory" } - -func (i *Inventory) AvailableQuantity() int { - return i.TotalQuantity - i.ReservedQuantity -} -``` - -- `InventoryResponse` DTO — adds computed `available_quantity` field -- `UpdateInventoryRequest` — `{ total_quantity int, binding:"required,min=0" }` -- `LowStockThreshold = 10` — constant; triggers `inventory.low_stock` event when available stock drops at or below this -- `OrderCreatedEvent` / `OrderCancelledEvent` — shape of messages from order-service: - ```go - type OrderCreatedEvent struct { - OrderID uuid.UUID `json:"id"` // matches Order.ID json tag - UserID uuid.UUID `json:"user_id"` - Items []OrderEventItem `json:"items"` - } - type OrderEventItem struct { - ProductID uuid.UUID `json:"product_id"` - Quantity int `json:"quantity"` - } - ``` - `OrderCancelledEvent` is identical in shape — same `Order` struct published by order-service. - -**`internal/domain/errors.go`** -- `ErrInventoryNotFound`, `ErrInsufficientStock`, `ErrInvalidQuantity` - -**`internal/domain/repository.go`** -```go -type InventoryRepository interface { - GetByProductID(productID uuid.UUID) (*Inventory, error) - SetTotalQuantity(productID uuid.UUID, quantity int) (*Inventory, error) - ReserveStock(productID uuid.UUID, quantity int) (*Inventory, error) - ReleaseStock(productID uuid.UUID, quantity int) (*Inventory, error) -} -``` - -**`internal/domain/service.go`** -```go -type InventoryService interface { - GetInventory(ctx context.Context, productID uuid.UUID) (*InventoryResponse, error) - SetInventory(ctx context.Context, productID uuid.UUID, req UpdateInventoryRequest) (*InventoryResponse, error) - HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error - HandleOrderCancelled(ctx context.Context, event OrderCreatedEvent) error -} -``` - -**`internal/domain/cache.go`** -```go -type InventoryCache interface { - GetInventory(ctx context.Context, productID uuid.UUID) (*Inventory, error) - SetInventory(ctx context.Context, inv *Inventory) error - InvalidateInventory(ctx context.Context, productID uuid.UUID) error -} -``` - -**`internal/domain/events.go`** -- `EventPublisher` interface: `Publish(ctx, topic string, payload any) error` + `Close() error` -- Consumed topics (constants, not published): - - `TopicOrderCreated = "order.created"` - - `TopicOrderCancelled = "order.cancelled"` -- Published topics: - - `TopicInventoryUpdated = "inventory.updated"` - - `TopicInventoryLowStock = "inventory.low_stock"` - ---- - -### Task 2 — Repository Layer - -**`internal/repository/inventory_repository.go`** - -- `GetByProductID` — returns `ErrInventoryNotFound` on GORM `ErrRecordNotFound` -- `SetTotalQuantity` — admin restock: uses `db.Save()` with upsert semantics (creates if not exists, updates if exists); bumps `Version` -- `ReserveStock` — uses optimistic locking: - ```go - result := db.Model(&Inventory{}). - Where("product_id = ? AND version = ? AND (total_quantity - reserved_quantity) >= ?", - productID, current.Version, quantity). - Updates(map[string]any{ - "reserved_quantity": gorm.Expr("reserved_quantity + ?", quantity), - "version": gorm.Expr("version + 1"), - "updated_at": time.Now(), - }) - if result.RowsAffected == 0 { - return nil, domain.ErrInsufficientStock - } - ``` - Returns `ErrInsufficientStock` if the WHERE clause misses (concurrent update or not enough stock). -- `ReleaseStock` — similar pattern; clamps `reserved_quantity` to minimum 0 via `GREATEST`: - ```go - Updates(map[string]any{ - "reserved_quantity": gorm.Expr("GREATEST(reserved_quantity - ?, 0)", quantity), - "version": gorm.Expr("version + 1"), - "updated_at": time.Now(), - }) - ``` - -> **No AutoMigrate** — the `inventory` table is owned by product-service. Inventory-service connects to the same DB and reads/writes the table without managing its schema. - ---- - -### Task 3 — Cache Layer - -**`internal/cache/inventory_cache.go`** -- Key: `inventory:` (TTL **5 minutes** — shorter than other caches since stock changes frequently) -- JSON marshal/unmarshal; Redis miss returns `nil, nil` - ---- - -### Task 4 — Kafka Events (Publisher + Consumer) - -**`internal/events/kafka_publisher.go`** -- Same pattern as other services: `kafkaPublisher` with `writers map[string]*kafka.Writer` - -**`internal/events/kafka_consumer.go`** - -Multi-topic consumer — subscribes to `order.created` AND `order.cancelled` with a single struct, two readers: - -```go -type KafkaConsumer struct { - readers []readerEntry - service domain.InventoryService -} - -type readerEntry struct { - reader *kafka.Reader - topic string -} - -func NewKafkaConsumer(brokers []string, service domain.InventoryService) *KafkaConsumer -``` - -- `Start(ctx)` — launches one goroutine per reader; each goroutine calls `handleMessage(topic, payload)` -- `handleMessage` — switches on topic, unmarshals the appropriate event type, calls the right service method -- `Close()` — closes all readers - -Group IDs: -- `order.created` → group `inventory-service-orders` -- `order.cancelled` → group `inventory-service-orders` (same group, different topic) - ---- - -### Task 5 — Service Layer - -**`internal/service/inventory_service.go`** - -**`GetInventory(ctx, productID)`** -1. Cache-aside: check `inventoryCache.GetInventory(ctx, productID)` -2. DB fallback on miss -3. Return `InventoryResponse` with computed `available_quantity` - -**`SetInventory(ctx, productID, req)`** -1. Call `repo.SetTotalQuantity(productID, req.TotalQuantity)` (upsert) -2. Invalidate + re-cache -3. Publish `inventory.updated` async -4. Check if available stock ≤ `LowStockThreshold` → publish `inventory.low_stock` async - -**`HandleOrderCreated(ctx, event)`** -- For each `event.Items`: - 1. Call `repo.ReserveStock(item.ProductID, item.Quantity)` - 2. On `ErrInsufficientStock`: log error, continue with remaining items (partial reservation is acceptable — could block order fulfillment in a real system, but acceptable for portfolio scope) - 3. Invalidate cache for affected product - 4. Publish `inventory.updated` async; if available stock ≤ threshold, also publish `inventory.low_stock` - -**`HandleOrderCancelled(ctx, event)`** -- For each `event.Items`: - 1. Call `repo.ReleaseStock(item.ProductID, item.Quantity)` - 2. Invalidate cache - 3. Publish `inventory.updated` async - ---- - -### Task 6 — Handler + Route Layers - -**`internal/handler/inventory_handler.go`** -- `InventoryHandler` struct with `service domain.InventoryService` -- `GetInventory(c)` — parse `:product_id` UUID param, call service, return 200/404 -- `SetInventory(c)` — parse `:product_id`, bind `UpdateInventoryRequest`, call service, return 200 -- `handleError(c, err)` — maps `ErrInventoryNotFound` → 404, `ErrInvalidQuantity` → 400, default → 500 -- No `getUserID` helper needed — admin identity is verified at the gateway; inventory-service trusts the request is admin - -**`internal/route/inventory_route.go`** -```go -func RegisterInventoryRoutes(router *gin.Engine, inventoryHandler *handler.InventoryHandler) { - api := router.Group("/") - api.GET("/inventory/:product_id", inventoryHandler.GetInventory) - api.PUT("/inventory/:product_id", inventoryHandler.SetInventory) -} -``` - ---- - -### Task 7 — cmd Bootstrap - -**`cmd/config.go`** -```go -type appConfig struct { - Port string - DatabaseURL string - RedisURL string - KafkaBrokers string -} -``` -Defaults: port 8085, `localhost:5433/products_db`, `localhost:6379`, `localhost:9092` - -> Note: default DATABASE_URL uses port 5433 (host-mapped port for products-db) for local dev. - -**`cmd/dotenv.go`** — identical pattern to all other services - -**`cmd/infrastructure.go`** -- `setupDatabase` with connection pooling -- **No `runMigrations`** — product-service owns the `inventory` table; inventory-service skips AutoMigrate -- `setupRedis` with 5s ping timeout -- `resolveGormLogLevel` - -**`cmd/kafka.go`** -- `inventoryPublishedTopics`: `TopicInventoryUpdated`, `TopicInventoryLowStock` -- `setupKafkaPublisher(brokers string) domain.EventPublisher` -- `setupKafkaConsumer(brokers string, svc domain.InventoryService) *events.KafkaConsumer` - - ensures `order.created` and `order.cancelled` topics exist (non-fatal if they already exist) -- `startKafkaConsumer(ctx, consumer)` -- `closeKafkaPublisher`, `parseBrokers`, `ensureTopics` — same helpers as other services - -**`cmd/run.go`** -```go -func Run() { - cfg := loadConfig() - db := setupDatabase(cfg.DatabaseURL) - redisClient := setupRedis(cfg.RedisURL) - publisher := setupKafkaPublisher(cfg.KafkaBrokers) - inventoryRepo := repository.NewInventoryRepository(db) - inventoryCache := cache.NewInventoryCache(redisClient) - inventorySvc := service.NewInventoryService(inventoryRepo, inventoryCache, publisher) - ctx, cancel := context.WithCancel(context.Background()) - consumer := setupKafkaConsumer(cfg.KafkaBrokers, inventorySvc) - startKafkaConsumer(ctx, consumer) - inventoryHandler := handler.NewInventoryHandler(inventorySvc) - router := setupRouter(inventoryHandler) - registerGracefulShutdown(db, redisClient, publisher, consumer, cancel) - router.Run(fmt.Sprintf(":%s", cfg.Port)) -} -``` - -**`cmd/server.go`** -- `setupRouter(*handler.InventoryHandler) *gin.Engine` — release mode, `/health`, `/metrics`, calls `RegisterInventoryRoutes` - ---- - -### Task 8 — Entry Point, Dockerfile, Env Files, docker-compose - -**`main.go`** — `cmd.Run()` - -**`Dockerfile`** — same multi-stage pattern: `golang:1.25-alpine` builder → `alpine:3.18` runtime; binary `inventory-service`; EXPOSE 8085 - -**`.env`** — local dev values: -``` -PORT=8085 -DATABASE_URL=postgres://auron:auron_pass@localhost:5433/products_db?sslmode=disable -REDIS_URL=redis://localhost:6379/0 -KAFKA_BROKERS=localhost:9092 -GORM_LOG_LEVEL=warn -``` - -**`.env.example`** — same with documented placeholders - -**`docker-compose.yml`** — update `inventory-service` block: -```yaml -environment: - - PORT=8085 - - DATABASE_URL=postgres://auron:auron_pass@products-db:5432/products_db?sslmode=disable - - REDIS_URL=redis://redis:6379/0 - - KAFKA_BROKERS=kafka:29092 -depends_on: - products-db: - condition: service_healthy - kafka: - condition: service_healthy -``` - ---- - -## Key Design Decisions - -| Decision | Choice | Reason | -|---|---|---| -| Shared database | inventory-service reads/writes `products_db` | Product and inventory are tightly coupled; avoids extra DB; consistent with docker-compose original design | -| No AutoMigrate | inventory-service skips it | product-service owns the `inventory` table schema; running AutoMigrate in both is safe but unnecessary | -| Reservation vs deduction | Reserve on `order.created`, release on `order.cancelled` | Available stock stays accurate; no HTTP call to order-service needed for `payment.completed` | -| Optimistic locking | `version` field incremented on every update | Prevents lost updates under concurrent order placement; `ErrInsufficientStock` returned if version mismatches | -| Multi-topic consumer | Single struct with two readers, one goroutine each | kafka-go Reader only supports one topic; two readers is the idiomatic approach | -| Low-stock threshold | Constant `LowStockThreshold = 10` | Simple; notification-service can consume `inventory.low_stock` to alert admins | -| go.mod module | `auron/inventory-service` | Matches pattern of all other services | - ---- - -## Dependencies - -``` -github.com/gin-gonic/gin v1.12.0 -github.com/google/uuid v1.6.0 -github.com/redis/go-redis/v9 v9.19.0 -github.com/segmentio/kafka-go v0.4.51 -gorm.io/driver/postgres v1.6.0 -gorm.io/gorm v1.31.1 -``` - -No Stripe SDK needed. Simpler dependency set than payment-service. diff --git a/services/notification-service/IMPLEMENTATION_PLAN.md b/services/notification-service/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 86f6b5c..0000000 --- a/services/notification-service/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,317 +0,0 @@ -# Notification Service — Implementation Plan - -## Overview - -The notification-service delivers transactional emails to users triggered by domain events -published on Kafka by other services. It consumes events, fetches any additional context it -needs, renders email content, and sends via SMTP (using Go's `net/smtp` standard library). - -No database is required — all state comes from incoming events. -No REST API endpoints — the service is entirely event-driven. -The `/health` endpoint (required by docker-compose healthcheck) is the only HTTP surface. - -**Port:** `8086` -**Kafka consumer group:** `notification-service` - ---- - -## Topics consumed and emails sent - -| Kafka Topic | Email sent | -|----------------------|--------------------------------------------------| -| `user.created` | Welcome email to new user | -| `order.created` | Order confirmation with item summary | -| `order.cancelled` | Order cancellation notice | -| `payment.completed` | Payment receipt / success confirmation | -| `payment.failed` | Payment failure notice with reason | -| `inventory.low_stock`| (internal) low-stock alert — no user email | - -> `inventory.low_stock` is consumed but emails are suppressed for now (logged only). -> `payment.created` is skipped — that event carries Stripe client_secret; not relevant here. - ---- - -## Directory structure - -``` -services/notification-service/ -├── IMPLEMENTATION_PLAN.md -├── main.go -├── Dockerfile -├── .env.example -├── go.mod / go.sum -├── cmd/ -│ ├── config.go # env → Config struct -│ ├── dotenv.go # .env loader (dev only) -│ ├── kafka.go # setupKafkaConsumer, closeKafkaConsumer -│ ├── run.go # Run(), registerGracefulShutdown -│ └── server.go # setupRouter (health only) -└── internal/ - ├── domain/ - │ ├── events.go # consumed topic constants + event structs - │ └── service.go # NotificationService interface - ├── email/ - │ └── smtp_sender.go # EmailSender interface + smtpSender impl - ├── events/ - │ └── kafka_consumer.go # multi-topic KafkaConsumer - ├── handler/ - │ └── health_handler.go # GET /health - ├── route/ - │ └── route.go # RegisterRoutes - └── service/ - └── notification_service.go # NotificationService impl -``` - ---- - -## Tasks - -### Task 1 — Domain: event structs + service interface - -**File:** `internal/domain/events.go` - -Topic constants for all consumed events: -``` -TopicUserCreated = "user.created" -TopicOrderCreated = "order.created" -TopicOrderCancelled = "order.cancelled" -TopicPaymentCompleted = "payment.completed" -TopicPaymentFailed = "payment.failed" -TopicInventoryLowStock = "inventory.low_stock" -``` - -Event structs (match producing service JSON tags exactly): - -- `UserCreatedEvent` — `id`, `email`, `name` -- `OrderCreatedEvent` — `id` (order ID), `user_id`, `total_amount`, `items[]` (`product_id`, `quantity`, `price`) -- `OrderCancelledEvent`— same shape as `OrderCreatedEvent` -- `PaymentEvent` — `id`, `order_id`, `user_id`, `amount`, `currency`, `status`, `failure_reason` -- `InventoryLowStockEvent` — `product_id`, `total_quantity`, `reserved_quantity` - -**File:** `internal/domain/service.go` - -```go -type NotificationService interface { - HandleUserCreated(ctx context.Context, event UserCreatedEvent) error - HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error - HandleOrderCancelled(ctx context.Context, event OrderCancelledEvent) error - HandlePaymentCompleted(ctx context.Context, event PaymentEvent) error - HandlePaymentFailed(ctx context.Context, event PaymentEvent) error -} -``` - ---- - -### Task 2 — Email sender: SMTP client - -**File:** `internal/email/smtp_sender.go` - -Interface: -```go -type EmailSender interface { - Send(to, subject, body string) error -} -``` - -Implementation `smtpSender`: -- Config: `host`, `port`, `from`, `user`, `pass`, `secure bool` -- Uses `net/smtp` standard library -- If `user == ""` — use `smtp.SendMail` without auth (relay/MailHog mode for dev) -- Otherwise — `smtp.PlainAuth` + `smtp.SendMail` -- Body format: plain-text `Content-Type: text/plain; charset=UTF-8` (no HTML templates for now) -- Helper `buildMessage(from, to, subject, body string) []byte` — formats RFC 2822 headers - -Constructor: `NewSMTPSender(host string, port int, from, user, pass string, secure bool) EmailSender` - ---- - -### Task 3 — Service layer: notification logic - -**File:** `internal/service/notification_service.go` - -`notificationService` struct — fields: `sender email.EmailSender` - -Each handler method: -1. Composes subject + plain-text body using Go string formatting -2. Calls `sender.Send(to, subject, body)` -3. Returns any error - -Email content per event: - -**HandleUserCreated** — to: `event.Email` -``` -Subject: Welcome to Auron, {{Name}}! -Body: Hi {{Name}}, your account has been created successfully. Start shopping at Auron! -``` - -**HandleOrderCreated** — to: derived from `user_id`; problem: no user email in event. -Resolution: embed the user email in the `OrderCreatedEvent` from the order-service side. -For now, log a warning and skip (order-service does not include email — it would require -a cross-service call). Store `user_id` in log; send to a no-op target. -**Alternative (chosen):** order-service includes `user_email` in the event. -Check if order-service event has `user_email`; if not, we skip sending and log. - -``` -Subject: Order Confirmed — #{{OrderID}} -Body: Your order {{OrderID}} for ${{TotalAmount}} has been placed successfully. - Items: (list each product_id × quantity) -``` - -**HandleOrderCancelled** — same resolution as above -``` -Subject: Order Cancelled — #{{OrderID}} -Body: Your order {{OrderID}} has been cancelled. -``` - -**HandlePaymentCompleted** -``` -Subject: Payment Received — ${{Amount}} {{Currency}} -Body: Your payment of ${{Amount}} {{Currency}} for order {{OrderID}} was successful. - Payment ID: {{ID}} -``` - -**HandlePaymentFailed** -``` -Subject: Payment Failed for Order #{{OrderID}} -Body: Your payment of ${{Amount}} {{Currency}} for order {{OrderID}} failed. - Reason: {{FailureReason}} - Please retry or contact support. -``` - -> `user_id` from payment events is a UUID — no email available without a user lookup. -> Payment events from the payment-service include `user_id` (UUID) but NOT email. -> For the initial implementation: log a warning, skip sending. -> A follow-up can add a user-service HTTP call to resolve email by user_id. - ---- - -### Task 4 — Kafka consumer: multi-topic consumer - -**File:** `internal/events/kafka_consumer.go` - -Same pattern as inventory-service: `[]readerEntry` with one `kafka.Reader` per topic. - -Topics: `user.created`, `order.created`, `order.cancelled`, `payment.completed`, `payment.failed`, `inventory.low_stock` - -Consumer group: `notification-service` - -`handleMessage(topic, value []byte)`: -- Switch on topic -- Unmarshal into correct event struct -- Call appropriate `NotificationService` method -- Log errors, do NOT retry (offset always committed) - -`Start(ctx context.Context)` — one goroutine per reader -`Close() error` — close all readers - ---- - -### Task 5 — Health handler + route - -**File:** `internal/handler/health_handler.go` - -```go -func (h *HealthHandler) GetHealth(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok", "service": "notification-service"}) -} -``` - -**File:** `internal/route/route.go` - -```go -func RegisterRoutes(router *gin.Engine, healthHandler *handler.HealthHandler) { - router.GET("/health", healthHandler.GetHealth) -} -``` - ---- - -### Task 6 — cmd bootstrap: config, infrastructure, kafka, server, run - -**File:** `cmd/config.go` - -```go -type Config struct { - Port string - SMTPHost string - SMTPPort int - SMTPFrom string - SMTPUser string - SMTPPass string - SMTPSecure bool - KafkaBrokers []string -} -``` -`loadConfig()` reads from env; `KAFKA_BROKERS` splits on `,`. - -**File:** `cmd/dotenv.go` — same `.env` loader pattern as other services - -**File:** `cmd/kafka.go` - -```go -func setupKafkaConsumer(brokers []string, svc domain.NotificationService) *events.KafkaConsumer -func startKafkaConsumer(ctx context.Context, consumer *events.KafkaConsumer) -func closeKafkaConsumer(consumer *events.KafkaConsumer) -``` - -**File:** `cmd/server.go` — `setupRouter` (health only, no auth middleware) - -**File:** `cmd/run.go` — `Run()` wires everything + `registerGracefulShutdown` - ---- - -### Task 7 — Entry point, Dockerfile, .env.example, docker-compose - -**File:** `main.go` -```go -package main -import "auron/notification-service/cmd" -func main() { cmd.Run() } -``` - -**File:** `Dockerfile` -- `golang:1.25-alpine` builder → `alpine:3.18` runtime -- Binary: `/notification-service` -- EXPOSE 8086 - -**File:** `.env.example` -``` -PORT=8086 -SMTP_HOST=localhost -SMTP_PORT=1025 -SMTP_FROM=noreply@auron.shop -SMTP_USER= -SMTP_PASS= -SMTP_SECURE=false -KAFKA_BROKERS=localhost:9092 -``` - -**docker-compose.yml** — update `notification-service` stanza: -- Add `KAFKA_BROKERS=kafka:29092` -- Add `depends_on: kafka: condition: service_healthy` - ---- - -## Dependencies (go.mod) - -``` -github.com/gin-gonic/gin -github.com/google/uuid -github.com/segmentio/kafka-go -github.com/joho/godotenv -``` - -No GORM, no Redis — this service has no database or cache. - ---- - -## Design decisions - -| Decision | Choice | Reason | -|----------|--------|--------| -| No database | Stateless | Emails are fire-and-forget; no state to persist | -| `net/smtp` not a library | Standard library | No extra dep; plain-text emails are sufficient | -| Dev mode (no SMTP auth) | `SMTP_USER=""` → no auth | Works with MailHog out of the box | -| Email for order/payment | Skip if no email in event | Cross-service user lookup adds coupling; deferrable | -| No retry on Kafka error | Log + commit | Idempotency not guaranteed; prevents consumer stall | -| Multi-reader consumer | One reader per topic | Same pattern as inventory-service; clean shutdown | diff --git a/services/order-service/IMPLEMENTATION_PLAN.md b/services/order-service/IMPLEMENTATION_PLAN.md deleted file mode 100644 index c5d9d0c..0000000 --- a/services/order-service/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,361 +0,0 @@ -# Order Service — Implementation Plan - -**Branch:** `feature/order-service` -**Port:** 8083 -**Database:** `orders_db` (PostgreSQL, orders-db:5434) -**Stack:** Go 1.25 · Gin · GORM · Redis · Kafka - ---- - -## Overview - -The Order Service owns two resources: - -| Resource | Responsibility | -|---|---| -| **Cart** | Per-user in-flight basket; items with product snapshots | -| **Order** | Confirmed purchase; immutable after creation | - -Gateway already routes these endpoints to port 8083: - -``` -Cart: GET /api/cart - POST /api/cart/items - PUT /api/cart/items/:id - DELETE /api/cart/items/:id - -Orders: GET /api/orders - POST /api/orders - GET /api/orders/:id - PUT /api/orders/:id/cancel -``` - -All routes require `Authorization: Bearer `. The gateway injects `X-User-ID` and `X-User-Role` headers; the service trusts these. - ---- - -## Domain Model - -### Entities - -``` -Cart - id uuid PK - user_id uuid UNIQUE (one cart per user) - created_at timestamp - updated_at timestamp - -CartItem - id uuid PK - cart_id uuid FK → carts - product_id uuid - product_name varchar (snapshot at time of add) - price float64 (snapshot at time of add) - quantity int - created_at timestamp - updated_at timestamp - -Order - id uuid PK - user_id uuid - status varchar (pending|confirmed|processing|shipped|delivered|cancelled) - total_amount float64 - shipping_name varchar (snapshot) - shipping_address varchar (snapshot) - created_at timestamp - updated_at timestamp - -OrderItem - id uuid PK - order_id uuid FK → orders - product_id uuid - product_name varchar (snapshot) - price float64 (snapshot) - quantity int - subtotal float64 (price × quantity, stored for history) - created_at timestamp -``` - -Price and product name are **snapshotted** on cart-add and order-create so historical orders are never affected by product edits. - -### Order Status Flow - -``` -pending → confirmed → processing → shipped → delivered - ↘ cancelled (any stage before shipped) -``` - ---- - -## External Dependencies - -The service calls **Product Service** (HTTP) to: -1. Validate a product exists and is active before adding to cart -2. Get the current price and name for the snapshot - -This is modelled as a `ProductClient` interface in the domain layer so the concrete HTTP implementation stays outside the domain. - ---- - -## Redis Cache Strategy - -| Key pattern | TTL | Evicted when | -|---|---|---| -| `cart:` | 24 h | item added/updated/removed, cart cleared | -| `order:` | 1 h | order status changes | -| `orders:user::page::limit:` | 5 min | new order created, order cancelled | - ---- - -## Kafka Events - -| Topic | Published when | -|---|---| -| `order.created` | Order confirmed from cart | -| `order.updated` | Status changes | -| `order.cancelled` | Order cancelled | - -Inventory Service and Notification Service consume these topics. - ---- - -## Implementation Tasks - -### Task 1 — Domain Layer - -**Files to create:** - -- `internal/domain/cart.go` — Cart, CartItem entities + TableName -- `internal/domain/order.go` — Order, OrderItem, OrderStatus entities + TableName -- `internal/domain/errors.go` — sentinel errors (ErrCartNotFound, ErrCartItemNotFound, ErrOrderNotFound, ErrOrderNotCancellable, ErrProductNotFound, ErrProductInactive, ErrInsufficientStock, ErrCartEmpty, ErrUnauthorized, ErrForbidden) -- `internal/domain/repository.go` — CartRepository + OrderRepository interfaces -- `internal/domain/service.go` — CartService + OrderService interfaces -- `internal/domain/cache.go` — CartCache + OrderCache interfaces -- `internal/domain/events.go` — EventPublisher interface + topic constants -- `internal/domain/client.go` — ProductClient interface (`GetProduct(id uuid.UUID) (*ProductSnapshot, error)`) - -Key interface signatures: - -```go -// CartService -GetCart(ctx, userID uuid.UUID) (*Cart, error) -AddItem(ctx, userID uuid.UUID, req AddItemRequest) (*Cart, error) -UpdateItem(ctx, userID, itemID uuid.UUID, qty int) (*Cart, error) -RemoveItem(ctx, userID, itemID uuid.UUID) error - -// OrderService -GetOrders(ctx, userID uuid.UUID, page, limit int) (*OrderListResponse, error) -CreateOrder(ctx, userID uuid.UUID, req CreateOrderRequest) (*Order, error) -GetOrderByID(ctx, userID, orderID uuid.UUID) (*Order, error) -CancelOrder(ctx, userID, orderID uuid.UUID) (*Order, error) -``` - ---- - -### Task 2 — Database Migrations - -**Files to create:** - -- `db/001_create_carts.up.sql` — `carts` + `cart_items` tables -- `db/002_create_orders.up.sql` — `orders` + `order_items` tables - -GORM AutoMigrate will handle the actual schema apply at startup (same pattern as product-service). The SQL files serve as documentation / manual fallback. - ---- - -### Task 3 — Repository Layer - -**Files to create:** - -- `internal/repository/cart_repository.go` — implements `domain.CartRepository` - - `GetCartByUserID(userID)` — preloads CartItems - - `GetCartItemByID(cartID, itemID)` — single item lookup - - `UpsertCart(cart)` — create or save - - `UpsertCartItem(item)` — create or save - - `DeleteCartItem(cartID, itemID)` — hard delete - - `ClearCart(cartID)` — delete all items (after order created) - -- `internal/repository/order_repository.go` — implements `domain.OrderRepository` - - `GetOrdersByUserID(userID, offset, limit)` — preloads OrderItems - - `GetOrderByID(orderID)` — preloads OrderItems - - `CreateOrder(order)` — creates order + items in a single transaction - - `UpdateOrderStatus(orderID, status)` — targeted update - ---- - -### Task 4 — Cache Layer - -**Files to create:** - -- `internal/cache/cart_cache.go` - - `GetCart(ctx, userID) (*domain.Cart, error)` - - `SetCart(ctx, cart) error` - - `InvalidateCart(ctx, userID) error` - -- `internal/cache/order_cache.go` - - `GetOrder(ctx, orderID) (*domain.Order, error)` - - `SetOrder(ctx, order) error` - - `InvalidateOrder(ctx, orderID) error` - - `GetOrderList(ctx, userID, page, limit) (*domain.OrderListResponse, error)` - - `SetOrderList(ctx, userID, page, limit, resp) error` - - `InvalidateOrderList(ctx, userID) error` — scans `orders:user::*` - ---- - -### Task 5 — Kafka Publisher - -**File to create:** - -- `internal/events/kafka_publisher.go` — implements `domain.EventPublisher` - - One `kafka.Writer` per topic (same pattern as product-service) - - JSON-serialises the payload, publishes with context + key = order ID - ---- - -### Task 6 — Product HTTP Client - -**File to create:** - -- `internal/client/product_client.go` — implements `domain.ProductClient` - - `GET {PRODUCT_SERVICE_URL}/products/{id}` - - Returns `domain.ProductSnapshot{ID, Name, Price, IsActive}` - - Returns `domain.ErrProductNotFound` on 404, `domain.ErrProductInactive` if `is_active == false` - - 5-second timeout - ---- - -### Task 7 — Service Layer - -**Files to create:** - -- `internal/service/cart_service.go` — implements `domain.CartService` - - `AddItem`: validate product via ProductClient → snapshot price/name → upsert cart + item → invalidate cache - - `UpdateItem`: validate item belongs to user's cart → update qty → invalidate cache - - `RemoveItem`: validate ownership → delete item → invalidate cache - - `GetCart`: cache-aside (cache → DB) - -- `internal/service/order_service.go` — implements `domain.OrderService` - - `CreateOrder`: load cart → validate not empty → build Order + OrderItems from cart snapshots → DB create in transaction → clear cart → cache order → invalidate order list → publish `order.created` - - `CancelOrder`: validate order belongs to user + status allows cancellation → update status → cache → publish `order.cancelled` - - `GetOrderByID`: cache-aside - - `GetOrders`: cache-aside (list cache, short TTL) - ---- - -### Task 8 — Handler + Route Layers - -**Files to create:** - -- `internal/handler/cart_handler.go` - - Reads `X-User-ID` header (set by gateway) to identify the caller - - `GetCart`, `AddItem`, `UpdateItem`, `RemoveItem` - -- `internal/handler/order_handler.go` - - `GetOrders`, `CreateOrder`, `GetOrderByID`, `CancelOrder` - - Request body for CreateOrder: `{ shipping_name, shipping_address }` - -- `internal/route/order_route.go` - - Registers all 8 routes on the Gin engine - ---- - -### Task 9 — cmd Bootstrap - -**Files to create** (same structure as product-service): - -- `cmd/config.go` — `appConfig` struct; loads PORT, DATABASE_URL, REDIS_URL, KAFKA_BROKERS, PRODUCT_SERVICE_URL from env -- `cmd/dotenv.go` — silent `.env` loader -- `cmd/infrastructure.go` — GORM setup + AutoMigrate (Cart, CartItem, Order, OrderItem) + Redis setup -- `cmd/kafka.go` — creates `kafka.Writer` per topic (`order.created`, `order.updated`, `order.cancelled`), `ensureTopics`, `closeKafkaPublisher` -- `cmd/server.go` — Gin engine, `/health`, `/metrics`, calls `RegisterOrderRoutes` -- `cmd/run.go` — wires full dependency graph (repo → cache → client → publisher → service → handler → routes), registers graceful shutdown (DB, Redis, Kafka) - ---- - -### Task 10 — Entry Point + Dockerfile - -**Files to create:** - -- `main.go` — calls `cmd.Run()` -- `Dockerfile` — multi-stage build (golang:1.25-alpine builder → alpine:3.18 runtime), port 8083 -- `.env` — local dev values -- `.env.example` - ---- - -### Task 11 — docker-compose Update - -Add missing env vars to the `order-service` block in `docker-compose.yml`: - -```yaml -- REDIS_URL=redis://redis:6379/0 -- KAFKA_BROKERS=kafka:29092 -- PRODUCT_SERVICE_URL=http://product-service:8082 -``` - -Also add `depends_on: redis` and `depends_on: kafka` conditions. - ---- - -## File Map - -``` -services/order-service/ -├── main.go -├── Dockerfile -├── .env -├── .env.example -├── go.mod -├── go.sum -├── cmd/ -│ ├── config.go -│ ├── dotenv.go -│ ├── infrastructure.go -│ ├── kafka.go -│ ├── run.go -│ └── server.go -├── db/ -│ ├── 001_create_carts.up.sql -│ └── 002_create_orders.up.sql -└── internal/ - ├── cache/ - │ ├── cart_cache.go - │ └── order_cache.go - ├── client/ - │ └── product_client.go - ├── domain/ - │ ├── cart.go - │ ├── order.go - │ ├── errors.go - │ ├── repository.go - │ ├── service.go - │ ├── cache.go - │ ├── events.go - │ └── client.go - ├── events/ - │ └── kafka_publisher.go - ├── handler/ - │ ├── cart_handler.go - │ └── order_handler.go - ├── repository/ - │ ├── cart_repository.go - │ └── order_repository.go - ├── route/ - │ └── order_route.go - └── service/ - ├── cart_service.go - └── order_service.go -``` - ---- - -## Key Decisions - -| Decision | Rationale | -|---|---| -| Price snapshot on cart-add | Historical orders stay accurate when product prices change | -| ProductClient interface in domain | Keeps domain testable; HTTP impl detail lives in `internal/client` | -| Cart cleared after order creation | Cart is single-use; users start a new one after checkout | -| No cart service auth check | Gateway already enforces auth; service trusts `X-User-ID` header | -| Kafka publish is async goroutine | Kafka unavailability never blocks HTTP response (same pattern as product-service) | -| `float64` for price | Consistent with product-service; avoids genproto/decimal GORM incompatibility | diff --git a/services/order-service/db/001_create_carts.up.sql b/services/order-service/db/001_create_carts.up.sql deleted file mode 100644 index 64667c3..0000000 --- a/services/order-service/db/001_create_carts.up.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Migration: 001_create_carts --- Purpose: Create carts and cart_items tables - -CREATE TABLE IF NOT EXISTS carts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT uq_carts_user_id UNIQUE (user_id) -); - -CREATE INDEX IF NOT EXISTS idx_carts_user_id ON carts(user_id); - -CREATE TABLE IF NOT EXISTS cart_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - cart_id UUID NOT NULL REFERENCES carts(id) ON DELETE CASCADE, - product_id UUID NOT NULL, - product_name VARCHAR(500) NOT NULL, - price DECIMAL(12, 2) NOT NULL, - quantity INT NOT NULL DEFAULT 1, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT chk_cart_items_quantity CHECK (quantity >= 1) -); - -CREATE INDEX IF NOT EXISTS idx_cart_items_cart_id ON cart_items(cart_id); -CREATE INDEX IF NOT EXISTS idx_cart_items_product_id ON cart_items(product_id); diff --git a/services/order-service/db/002_create_orders.up.sql b/services/order-service/db/002_create_orders.up.sql deleted file mode 100644 index b23ff85..0000000 --- a/services/order-service/db/002_create_orders.up.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Migration: 002_create_orders --- Purpose: Create orders and order_items tables - -CREATE TABLE IF NOT EXISTS orders ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'pending', - total_amount DECIMAL(12, 2) NOT NULL, - shipping_name VARCHAR(255) NOT NULL, - shipping_address TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT chk_orders_status CHECK ( - status IN ('pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled') - ), - CONSTRAINT chk_orders_total_amount CHECK (total_amount >= 0) -); - -CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); -CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); -CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at DESC); - -CREATE TABLE IF NOT EXISTS order_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, - product_id UUID NOT NULL, - product_name VARCHAR(500) NOT NULL, - price DECIMAL(12, 2) NOT NULL, - quantity INT NOT NULL, - subtotal DECIMAL(12, 2) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT chk_order_items_quantity CHECK (quantity >= 1), - CONSTRAINT chk_order_items_subtotal CHECK (subtotal >= 0) -); - -CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); -CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id); diff --git a/services/payment-service/IMPLEMENTATION_PLAN.md b/services/payment-service/IMPLEMENTATION_PLAN.md deleted file mode 100644 index e0d20d7..0000000 --- a/services/payment-service/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,399 +0,0 @@ -# Payment Service — Implementation Plan - -## Overview - -The payment service (port **8084**) integrates with **Stripe** to handle payment processing for orders. -It is a **Kafka consumer + HTTP API** hybrid service: - -- Consumes `order.created` events → creates a Stripe PaymentIntent → stores Payment record -- Exposes HTTP endpoints for payment lookup and Stripe webhook ingestion -- Publishes `payment.created`, `payment.completed`, `payment.failed` Kafka events for downstream consumers (e.g., order-service to update order status, notification-service to email the user) - -### Gateway Routes (already wired) - -| Method | Path | Auth | Purpose | -|---|---|---|---| -| `GET` | `/api/payments/:id` | Required | Get payment details by payment UUID | -| `POST` | `/api/payments/webhook/stripe` | None (Stripe signs) | Stripe webhook handler | - -### Payment Lifecycle - -``` -Frontend creates order - ↓ -order-service → publishes order.created (Kafka) - ↓ -payment-service consumes order.created - ↓ -Creates Stripe PaymentIntent → stores Payment(status=pending, client_secret) - ↓ -Publishes payment.created (contains payment_id + client_secret for frontend) - ↓ -Frontend uses client_secret + Stripe.js to confirm payment - ↓ -Stripe fires POST /api/payments/webhook/stripe - ↓ -payment-service verifies webhook signature → updates status - ↓ -Publishes payment.completed or payment.failed -``` - ---- - -## Folder Structure - -``` -services/payment-service/ -├── cmd/ -│ ├── config.go # env vars → appConfig struct -│ ├── dotenv.go # load .env file in non-production -│ ├── infrastructure.go # setupDatabase, setupRedis, runMigrations -│ ├── kafka.go # setupKafkaPublisher, startKafkaConsumer -│ ├── run.go # wire everything together -│ └── server.go # setupRouter, registerGracefulShutdown -├── db/ -│ └── 001_create_payments.up.sql -├── internal/ -│ ├── cache/ -│ │ └── payment_cache.go -│ ├── client/ -│ │ └── stripe_client.go -│ ├── domain/ -│ │ ├── payment.go # Payment entity, PaymentStatus, DTOs -│ │ ├── errors.go # sentinel errors -│ │ ├── repository.go # PaymentRepository interface -│ │ ├── service.go # PaymentService interface -│ │ ├── cache.go # PaymentCache interface -│ │ ├── events.go # EventPublisher interface + topic constants -│ │ └── client.go # StripeClient interface -│ ├── events/ -│ │ ├── kafka_publisher.go -│ │ └── kafka_consumer.go -│ ├── handler/ -│ │ └── payment_handler.go -│ ├── middleware/ -│ │ └── stripe_webhook.go # raw body capture for signature verification -│ ├── repository/ -│ │ └── payment_repository.go -│ ├── route/ -│ │ └── payment_route.go -│ └── service/ -│ └── payment_service.go -├── main.go -├── Dockerfile -├── go.mod -├── .env -└── .env.example -``` - ---- - -## Tasks - -### Task 1 — Domain Layer - -Create all files under `internal/domain/`: - -**`payment.go`** -- `PaymentStatus` type (`pending`, `processing`, `completed`, `failed`, `refunded`) -- `Payment` struct with GORM tags: - - `id uuid`, `order_id uuid` (unique index), `user_id uuid`, `amount float64`, `currency varchar(10) default 'usd'` - - `status varchar(50) default 'pending'`, `stripe_payment_intent_id varchar(255)`, `stripe_client_secret text` - - `failure_reason text`, `created_at`, `updated_at` -- `PaymentResponse` DTO — excludes `stripe_client_secret` for normal reads -- `PaymentInitResponse` DTO — includes `stripe_client_secret` (returned only on `payment.created` event, never via HTTP) -- `OrderCreatedEvent` struct — shape of the Kafka message from order-service: `{order_id, user_id, total_amount, items[]}` - -**`errors.go`** -- `ErrPaymentNotFound`, `ErrPaymentAlreadyExists`, `ErrInvalidWebhookSignature`, `ErrForbidden`, `ErrUnauthorized` - -**`repository.go`** -```go -type PaymentRepository interface { - GetPaymentByID(id uuid.UUID) (*Payment, error) - GetPaymentByOrderID(orderID uuid.UUID) (*Payment, error) - CreatePayment(payment *Payment) (*Payment, error) - UpdatePaymentStatus(id uuid.UUID, status PaymentStatus, failureReason string) (*Payment, error) - UpdateStripePaymentIntentID(id uuid.UUID, intentID, clientSecret string) (*Payment, error) -} -``` - -**`service.go`** -```go -type PaymentService interface { - GetPaymentByID(ctx context.Context, userID, paymentID uuid.UUID) (*PaymentResponse, error) - HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error - HandleStripeWebhook(ctx context.Context, payload []byte, signature string) error -} -``` - -**`cache.go`** -```go -type PaymentCache interface { - GetPayment(ctx context.Context, paymentID uuid.UUID) (*Payment, error) - SetPayment(ctx context.Context, payment *Payment) error - InvalidatePayment(ctx context.Context, paymentID uuid.UUID) error -} -``` - -**`events.go`** -- `EventPublisher` interface with `Publish(topic string, key string, payload any) error` and `Close() error` -- Constants: `TopicPaymentCreated = "payment.created"`, `TopicPaymentCompleted = "payment.completed"`, `TopicPaymentFailed = "payment.failed"` - -**`client.go`** -```go -type StripeClient interface { - CreatePaymentIntent(ctx context.Context, amount float64, currency string, metadata map[string]string) (intentID, clientSecret string, err error) -} -``` - ---- - -### Task 2 — DB Migration - -**`db/001_create_payments.up.sql`** -```sql -CREATE TABLE IF NOT EXISTS payments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - order_id UUID NOT NULL, - user_id UUID NOT NULL, - amount DECIMAL(12,2) NOT NULL, - currency VARCHAR(10) NOT NULL DEFAULT 'usd', - status VARCHAR(50) NOT NULL DEFAULT 'pending', - stripe_payment_intent_id VARCHAR(255), - stripe_client_secret TEXT, - failure_reason TEXT, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT chk_payments_status CHECK ( - status IN ('pending','processing','completed','failed','refunded') - ), - CONSTRAINT chk_payments_amount CHECK (amount > 0) -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_order_id ON payments(order_id); -CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id); -CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status); -``` - ---- - -### Task 3 — Repository Layer - -**`internal/repository/payment_repository.go`** -- GORM implementation of `domain.PaymentRepository` -- `GetPaymentByID` and `GetPaymentByOrderID` return `ErrPaymentNotFound` on GORM `ErrRecordNotFound` -- `UpdatePaymentStatus`: updates `status`, `failure_reason`, and `updated_at` in a single `db.Model().Updates()` call -- `UpdateStripePaymentIntentID`: sets `stripe_payment_intent_id` and `stripe_client_secret` - ---- - -### Task 4 — Cache Layer - -**`internal/cache/payment_cache.go`** -- `PaymentCache` struct wrapping `*redis.Client` -- Key: `payment:` (TTL 1h) -- JSON marshal/unmarshal; miss returns `nil, nil` - ---- - -### Task 5 — Kafka Events (Publisher + Consumer) - -**`internal/events/kafka_publisher.go`** -- Same pattern as order-service: `kafkaPublisher` with `writers map[string]*kafka.Writer` -- `Publish(topic, key string, payload any) error` — JSON-marshals payload, writes message -- `Close() error` - -**`internal/events/kafka_consumer.go`** -- `KafkaConsumer` struct: `reader *kafka.Reader`, `paymentService domain.PaymentService`, `logger` -- `Start(ctx context.Context)` — goroutine reading messages from `order.created` topic, group `payment-service` -- On message: unmarshal `domain.OrderCreatedEvent`, call `paymentService.HandleOrderCreated(ctx, event)` -- Log errors, commit offset, never crash — errors are non-fatal -- `Close() error` - ---- - -### Task 6 — Stripe Client - -**`internal/client/stripe_client.go`** -- `stripeClient` struct with `secretKey string` -- Implements `domain.StripeClient` -- `CreatePaymentIntent`: calls Stripe Go SDK `paymentintent.New()` with amount (converted to cents), currency, and metadata (`order_id`, `user_id`) -- Returns `intentID` and `clientSecret` - -**Dependencies to add:** -``` -github.com/stripe/stripe-go/v76 -``` - ---- - -### Task 7 — Service Layer - -**`internal/service/payment_service.go`** - -**`HandleOrderCreated(ctx, event)`** -1. Check `GetPaymentByOrderID` — if already exists, return nil (idempotent) -2. Call `stripeClient.CreatePaymentIntent(ctx, event.TotalAmount, "usd", metadata)` -3. Build and `CreatePayment` record (status=pending, stripe IDs set) -4. Cache the payment -5. Publish `payment.created` event asynchronously (contains `payment_id`, `order_id`, `user_id`, `client_secret`) - -**`GetPaymentByID(ctx, userID, paymentID)`** -1. Cache-aside: check cache first -2. DB fallback on miss -3. Ownership check: `payment.UserID != userID` → `ErrForbidden` -4. Return `PaymentResponse` (no client_secret) - -**`HandleStripeWebhook(ctx, payload, signature)`** -1. Construct Stripe event: `webhook.ConstructEvent(payload, signature, webhookSecret)` → error → `ErrInvalidWebhookSignature` -2. Switch on event type: - - `payment_intent.succeeded` → `UpdatePaymentStatus(completed)` → publish `payment.completed` async - - `payment_intent.payment_failed` → `UpdatePaymentStatus(failed, failureReason)` → publish `payment.failed` async - - `payment_intent.processing` → `UpdatePaymentStatus(processing)` -3. Invalidate and re-cache payment after status update - ---- - -### Task 8 — Handler + Route Layers - -**`internal/handler/payment_handler.go`** -- `PaymentHandler` struct with `paymentService domain.PaymentService` -- `getUserID(c *gin.Context) (uuid.UUID, error)` — reads `X-User-ID` header -- `GetPaymentByID(c *gin.Context)` — parse `:id` param, call service, return 200/404/403 -- `HandleStripeWebhook(c *gin.Context)` — reads raw body (from context, set by middleware), reads `Stripe-Signature` header, calls service, always returns 200 (Stripe retries on non-200) -- `handleError(c, err)` — maps domain errors to status codes - -**`internal/middleware/stripe_webhook.go`** -- Gin middleware that reads and buffers the raw request body into `c.Set("rawBody", body)` before `c.Next()` -- Required because Stripe signature verification needs the exact raw bytes, and `c.Request.Body` is consumed after `ShouldBindJSON` - -**`internal/route/payment_route.go`** -```go -func RegisterPaymentRoutes(router *gin.Engine, paymentHandler *handler.PaymentHandler) { - api := router.Group("/") - api.GET("/payments/:id", paymentHandler.GetPaymentByID) - api.POST("/payments/webhook/stripe", middleware.CaptureRawBody(), paymentHandler.HandleStripeWebhook) -} -``` - ---- - -### Task 9 — cmd Bootstrap - -**`cmd/config.go`** -```go -type appConfig struct { - Port string - DatabaseURL string - RedisURL string - KafkaBrokers string - StripeSecretKey string - StripeWebhookSecret string -} -``` -Defaults: port 8084, localhost:5435, localhost:6379, localhost:9092 - -**`cmd/dotenv.go`** — identical pattern to order-service - -**`cmd/infrastructure.go`** -- `setupDatabase` with connection pooling -- `runMigrations` — AutoMigrate `domain.Payment` -- `setupRedis` — ParseURL + Ping - -**`cmd/kafka.go`** -- `paymentTopics`: TopicPaymentCreated, TopicPaymentCompleted, TopicPaymentFailed -- `setupKafkaPublisher(brokers string) domain.EventPublisher` -- `setupKafkaConsumer(brokers string, svc domain.PaymentService) *events.KafkaConsumer` -- `startKafkaConsumer(consumer *events.KafkaConsumer)` — launches goroutine - -**`cmd/run.go`** -```go -func Run() { - cfg := loadConfig() - db := setupDatabase(cfg.DatabaseURL) - runMigrations(db) - redisClient := setupRedis(cfg.RedisURL) - publisher := setupKafkaPublisher(cfg.KafkaBrokers) - paymentRepo := repository.NewPaymentRepository(db) - paymentCache := cache.NewPaymentCache(redisClient) - stripeClient := client.NewStripeClient(cfg.StripeSecretKey) - paymentSvc := service.NewPaymentService(paymentRepo, paymentCache, stripeClient, publisher, cfg.StripeWebhookSecret) - consumer := setupKafkaConsumer(cfg.KafkaBrokers, paymentSvc) - startKafkaConsumer(consumer) - paymentHandler := handler.NewPaymentHandler(paymentSvc) - router := setupRouter(paymentHandler) - registerGracefulShutdown(db, redisClient, publisher, consumer) - router.Run(fmt.Sprintf(":%s", cfg.Port)) -} -``` - -**`cmd/server.go`** -- `setupRouter(*handler.PaymentHandler) *gin.Engine` — release mode, /health, /metrics, calls `RegisterPaymentRoutes` -- `registerGracefulShutdown` — SIGTERM/SIGINT handler closing DB, Redis, Kafka publisher and consumer - ---- - -### Task 10 — Entry Point, Dockerfile, and Env Files - -**`main.go`** — `cmd.Run()` - -**`Dockerfile`** — same multi-stage pattern: `golang:1.25-alpine` builder → `alpine:3.18` runtime; binary named `payment-service`; EXPOSE 8084 - -**`.env`** — local dev values -``` -PORT=8084 -DATABASE_URL=postgres://auron:auron_pass@localhost:5435/payments_db?sslmode=disable -REDIS_URL=redis://localhost:6379/0 -KAFKA_BROKERS=localhost:9092 -STRIPE_SECRET_KEY=sk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... -GORM_LOG_LEVEL=warn -``` - -**`.env.example`** — same with placeholder values - ---- - -### Task 11 — docker-compose Wiring - -Update `docker-compose.yml` `payment-service` environment block: -```yaml -- REDIS_URL=redis://redis:6379/0 -- KAFKA_BROKERS=kafka:29092 -- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} -- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} -``` - -Add `kafka` to `payment-service.depends_on` (after payments-db). - ---- - -## Key Design Decisions - -| Decision | Choice | Reason | -|---|---|---| -| Stripe integration | PaymentIntents API | Supports SCA, supports card, wallet, BNPL via `automatic_payment_methods` | -| Payment initiation | Kafka consumer (`order.created`) | Decoupled — order-service doesn't need to call payment-service HTTP | -| Webhook raw body | Middleware that caches raw bytes | Stripe signature verification requires exact bytes; Gin's binding consumes the body | -| Idempotency | Check `GetPaymentByOrderID` before creating | Prevents duplicate Stripe intents if `order.created` is delivered multiple times | -| client_secret exposure | Only via `payment.created` Kafka event | Never exposed via HTTP API to avoid interception; downstream services forward to frontend | -| Stripe amount | `int64(amount * 100)` cents | Stripe API requires smallest currency unit | -| Webhook response | Always return 200 | Stripe retries on 4xx/5xx; log errors but don't fail the HTTP response | -| KafkaBrokers for consumer | `order.created` topic, group `payment-service` | Group ID ensures each message is processed exactly once per service instance | -| go.mod module | `auron/payment-service` | Matches pattern of all other services | - ---- - -## Dependencies - -``` -github.com/gin-gonic/gin v1.12.0 -github.com/google/uuid v1.6.0 -github.com/redis/go-redis/v9 v9.19.0 -github.com/segmentio/kafka-go v0.4.51 -gorm.io/driver/postgres v1.6.0 -gorm.io/gorm v1.31.1 -github.com/stripe/stripe-go/v76 v76.x.x -github.com/joho/godotenv v1.5.1 -``` diff --git a/services/payment-service/db/001_create_payments.up.sql b/services/payment-service/db/001_create_payments.up.sql deleted file mode 100644 index f61c66d..0000000 --- a/services/payment-service/db/001_create_payments.up.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE IF NOT EXISTS payments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - order_id UUID NOT NULL, - user_id UUID NOT NULL, - amount DECIMAL(12,2) NOT NULL, - currency VARCHAR(10) NOT NULL DEFAULT 'usd', - status VARCHAR(50) NOT NULL DEFAULT 'pending', - stripe_payment_intent_id VARCHAR(255), - stripe_client_secret TEXT, - failure_reason TEXT, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT chk_payments_status CHECK ( - status IN ('pending','processing','completed','failed','refunded') - ), - CONSTRAINT chk_payments_amount CHECK (amount > 0) -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_order_id ON payments(order_id); -CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id); -CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status); diff --git a/services/product-service/FULLTEXT_SEARCH.md b/services/product-service/FULLTEXT_SEARCH.md deleted file mode 100644 index 716bd99..0000000 --- a/services/product-service/FULLTEXT_SEARCH.md +++ /dev/null @@ -1,189 +0,0 @@ -# Full-Text Search Setup Guide - -## Overview - -The Product Service uses PostgreSQL's built-in full-text search capabilities via `tsvector` and `plainto_tsquery()`. - -## How It Works - -### 1. Database Layer (PostgreSQL) - -The `search_vector` column in the `products` table stores a tsvector (text search vector) that is automatically populated by a PostgreSQL trigger on every INSERT or UPDATE. - -```sql --- Trigger automatically runs: -NEW.search_vector := to_tsvector('english', COALESCE(NEW.name, '') || ' ' || COALESCE(NEW.description, '')); -``` - -### 2. Query Layer (GORM + Raw SQL) - -To search products, use PostgreSQL's `@@` (match) operator with `plainto_tsquery()`: - -```sql -SELECT * FROM products -WHERE search_vector @@ plainto_tsquery('english', 'laptop gaming') - AND is_active = true; -``` - -### 3. Go Implementation (Repository Layer) - -In `internal/repository/product_repository.go`: - -```go -func (r *ProductRepository) ListProducts(filter domain.ProductFilter) (*domain.ProductListResponse, error) { - query := r.db.Model(&domain.Product{}).Where("is_active = ?", true) - - // Full-text search - if filter.Q != "" { - // Use raw SQL for tsvector queries (GORM doesn't support this natively) - query = query.Where("search_vector @@ plainto_tsquery('english', ?)", filter.Q) - } - - // ... rest of filtering, sorting, pagination -} -``` - -## Search Features - -### Phrase Search -``` -Query: "wireless mouse" -Matches: Products containing both "wireless" AND "mouse" -``` - -### Prefix Matching -``` -Query: "lap*" -Matches: "laptop", "lapse", "lapel", etc. -``` - -### Weighted Results (Future Enhancement) -```sql --- Rank results by relevance -SELECT *, ts_rank(search_vector, plainto_tsquery('english', 'laptop')) AS rank -FROM products -WHERE search_vector @@ plainto_tsquery('english', 'laptop') -ORDER BY rank DESC; -``` - -## Testing Full-Text Search - -### 1. Manual Test via SQL - -```sql --- Insert test product -INSERT INTO products (name, description, price, category_id) -VALUES ('Gaming Laptop Pro', 'High-performance laptop with RTX 4090 and 32GB RAM', 2499.99, 'some-uuid'); - --- Verify search_vector is populated -SELECT id, name, search_vector FROM products WHERE name = 'Gaming Laptop Pro'; - --- Test search query -SELECT id, name, description -FROM products -WHERE search_vector @@ plainto_tsquery('english', 'laptop'); - --- Test multi-word search -SELECT id, name, description -FROM products -WHERE search_vector @@ plainto_tsquery('english', 'gaming laptop'); -``` - -### 2. API Test via curl - -```bash -# Search for "laptop" -curl "http://localhost:8080/api/products?q=laptop" - -# Search for "gaming laptop" -curl "http://localhost:8080/api/products?q=gaming+laptop" - -# Search with filters -curl "http://localhost:8080/api/products?q=laptop&min_price=1000&max_price=3000&sort=price_asc" -``` - -## Migration - -Run the migration to setup full-text search: - -```bash -# Via Docker -docker compose exec product-service sh -# Inside container -psql $DATABASE_URL -f /app/db/004_create_search_index.up.sql -``` - -Or let GORM's `AutoMigrate` create the basic structure, then run the trigger setup: - -```go -// In cmd/infrastructure.go -func runMigrations(db *gorm.DB) error { - // GORM creates the basic table structure - if err := db.AutoMigrate(&domain.Product{}, &domain.Category{}, &domain.Inventory{}); err != nil { - return err - } - - // PostgreSQL-specific setup (tsvector trigger) - return bootstrapSearchIndex(db) -} - -func bootstrapSearchIndex(db *gorm.DB) error { - // Execute the SQL migration - sqlContent, err := os.ReadFile("db/004_create_search_index.up.sql") - if err != nil { - return fmt.Errorf("failed to read migration file: %w", err) - } - - if err := db.Exec(string(sqlContent)).Error; err != nil { - return fmt.Errorf("failed to execute search index migration: %w", err) - } - - return nil -} -``` - -## Performance Notes - -| Aspect | Details | -|---|---| -| Index Type | GIN (Generalized Inverted Index) | -| Text Config | `english` (uses English stemmer) | -| Trigger | `BEFORE INSERT OR UPDATE` (automatic) | -| Query Speed | ~1-5ms for 100k products | -| Index Size | ~20-30% of text column size | - -## Troubleshooting - -### Search Returns No Results - -1. **Check if search_vector is populated:** - ```sql - SELECT id, name, search_vector FROM products LIMIT 5; - ``` - -2. **Manually trigger update:** - ```sql - UPDATE products SET search_vector = to_tsvector('english', name || ' ' || COALESCE(description, '')); - ``` - -3. **Verify trigger exists:** - ```sql - SELECT trigger_name, event_manipulation - FROM information_schema.triggers - WHERE trigger_name = 'products_search_vector_update'; - ``` - -### Search Returns Wrong Results - -PostgreSQL's `plainto_tsquery()` uses AND logic by default. "laptop gaming" matches products containing **both** words, not necessarily in that order. - -For exact phrase matching, use `phraseto_tsquery()`: -```sql -WHERE search_vector @@ phraseto_tsquery('english', 'gaming laptop') -``` - -## References - -- [PostgreSQL Full-Text Search](https://www.postgresql.org/docs/current/textsearch.html) -- [tsvector Documentation](https://www.postgresql.org/docs/current/datatype-textsearch.html) -- [GIN Index Documentation](https://www.postgresql.org/docs/current/gin.html) diff --git a/services/product-service/IMPLEMENTATION_PLAN.md b/services/product-service/IMPLEMENTATION_PLAN.md deleted file mode 100644 index f3279c4..0000000 --- a/services/product-service/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,1567 +0,0 @@ -# Product Service Implementation Plan - -> **Service:** Product Catalog Management -> **Port:** `8082` -> **Database:** `products_db` (PostgreSQL :5433) -> **Stack:** Go 1.21 · Gin · GORM · Redis · Kafka -> **Architecture Pattern:** Layered (Inner → Outer) — Domain → Repository → Cache → Service → Handler → Route → Bootstrap - ---- - -## Table of Contents - -1. [Overview & Scope](#1-overview--scope) -2. [Existing State Analysis](#2-existing-state-analysis) -3. [Architecture Flow](#3-architecture-flow) -4. [Layer 1: Domain (Core)](#4-layer-1-domain-core) -5. [Layer 2: Repository](#5-layer-2-repository) -6. [Layer 3: Cache](#6-layer-3-cache) -7. [Layer 4: Service](#7-layer-4-service) -8. [Layer 5: Handler](#8-layer-5-handler) -9. [Layer 6: Route](#9-layer-6-route) -10. [Layer 7: Bootstrap (Outer)](#10-layer-7-bootstrap-outer) -11. [Database & Migrations](#11-database--migrations) -12. [Kafka Integration](#12-kafka-integration) -13. [Configuration & Environment](#13-configuration--environment) -14. [Implementation Checklist](#14-implementation-checklist) -15. [File Structure](#15-file-structure) - ---- - -## 1. Overview & Scope - -### Endpoints (per Technical Plan §4.3) - -| Method | Path | Description | Auth | -|---|---|---|---| -| `GET` | `/products` | List products (paginated, filtered, searched) | No | -| `GET` | `/products/:id` | Get product detail | No | -| `POST` | `/products` | Create product | Admin | -| `PUT` | `/products/:id` | Update product | Admin | -| `DELETE` | `/products/:id` | Delete product | Admin | -| `GET` | `/categories` | List categories | No | -| `POST` | `/categories` | Create category | Admin | - -### Required Features - -- **Full-text search** via PostgreSQL `tsvector` + `plainto_tsquery` -- **Filtering**: `category_id`, `min_price`, `max_price` -- **Sorting**: `price_asc`, `price_desc`, `newest`, `name_asc`, `name_desc` -- **Pagination**: `page`, `limit` (defaults: page=1, limit=20, max=100) -- **Redis caching**: product detail + list with 5-min TTL -- **Cache invalidation**: on any product mutation (create/update/delete) -- **Kafka events**: publish product lifecycle events (future: inventory sync) - -### Key Constraints (from Technical Plan) - -- Products table has `search_vector` tsvector column with GIN index -- Categories support hierarchical structure (`parent_id`) -- Inventory table shares the same `products_db` (separate service reads/writes it) -- All write operations require admin role -- Cache keys: `product:{id}` for detail, `products:list:{hash}` for listings - ---- - -## 2. Existing State Analysis - -### ✅ Already Implemented - -| File | Status | Notes | -|---|---|---| -| `internal/domain/product.go` | ✅ Complete | Product, Category, Inventory models + all DTOs (request/response) | -| `internal/domain/errors.go` | ⚠️ Partial | Only `ErrProductNotFound`, `ErrInvalidProductID` — needs expansion | -| `internal/cache/` | ✅ Dir exists | Empty — implementation needed | -| `internal/domain/` | ✅ Dir exists | Has models + DTOs | -| `internal/repository/` | ✅ Dir exists | Empty — implementation needed | -| `internal/service/` | ✅ Dir exists | Empty — implementation needed | -| `internal/handler/` | ✅ Dir exists | Empty — implementation needed | -| `internal/middleware/` | ✅ Dir exists | Empty — may need admin auth middleware | -| `internal/events/` | ✅ Dir exists | Empty — for Kafka event publishing | -| `go.mod` / `go.sum` | ✅ Exists | Module defined, dependencies ready | - -### ❌ Missing (to be created) - -- Domain interfaces: `repository.go`, `service.go`, `cache.go` -- Repository implementation: `product_repository.go` -- Cache implementation: `product_cache.go` -- Service implementation: `product_service.go` -- Handler implementation: `product_handler.go` -- Route registration: `product_route.go` -- Bootstrap: `main.go`, `cmd/config.go`, `cmd/dotenv.go`, `cmd/infrastructure.go`, `cmd/server.go`, `cmd/run.go` -- Dockerfile -- `.env` / `.env.example` -- Database migrations directory - ---- - -## 3. Architecture Flow - -``` -┌─────────────────────────────────────────────────────────┐ -│ BOOTSTRAP (Outer) │ -│ main.go → cmd/run.go → cmd/infrastructure.go │ -│ ↓ Config loading, DB setup, Redis setup, DI wiring │ -└──────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────▼──────────────────────────────────┐ -│ ROUTE LAYER │ -│ internal/route/product_route.go │ -│ ↓ Route registration, middleware application │ -└──────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────▼──────────────────────────────────┐ -│ HANDLER LAYER │ -│ internal/handler/product_handler.go │ -│ ↓ HTTP binding, validation, error→status mapping │ -└──────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────▼──────────────────────────────────┐ -│ SERVICE LAYER │ -│ internal/service/product_service.go │ -│ ↓ Business logic, cache orchestration, validation │ -└──────────┬──────────────────────┬───────────────────────┘ - │ │ -┌──────────▼──────┐ ┌──────────▼──────────┐ -│ REPOSITORY │ │ CACHE │ -│ (PostgreSQL) │ │ (Redis) │ -│ product_repo.go│ │ product_cache.go │ -└─────────────────┘ └─────────────────────┘ - │ │ -┌──────────▼──────────────────────▼───────────────────────┐ -│ DOMAIN (Core) │ -│ internal/domain/{product,errors,repository, │ -│ service,cache}.go │ -│ Entities, interfaces, error types, DTOs │ -└──────────────────────────────────────────────────────────┘ -``` - -**Dependency Direction (Inner → Outer):** -``` -Domain ← Repository -Domain ← Cache -Domain + Repository + Cache ← Service -Domain + Service ← Handler -Service + Handler ← Route -All layers ← Bootstrap -``` - ---- - -## 4. Layer 1: Domain (Core) - -**Location:** `internal/domain/` -**Purpose:** Define the business entities, interfaces, and error types. No implementation logic — only contracts. - -### 4.1 Update `errors.go` - -Expand existing errors to cover all product service scenarios: - -```go -package domain - -import "errors" - -var ( - // Product errors - ErrProductNotFound = errors.New("product not found") - ErrInvalidProductID = errors.New("invalid product ID") - ErrProductAlreadyExists = errors.New("product with this name already exists") - - // Category errors - ErrCategoryNotFound = errors.New("category not found") - ErrCategorySlugExists = errors.New("category with this slug already exists") - ErrInvalidCategoryID = errors.New("invalid category ID") - - // Validation errors - ErrInvalidSortParam = errors.New("invalid sort parameter. allowed: price_asc, price_desc, newest, name_asc, name_desc") - ErrInvalidPageParam = errors.New("page must be >= 1") - ErrInvalidLimitParam = errors.New("limit must be between 1 and 100") - ErrPriceMustBePositive = errors.New("price must be positive") - - // Generic - ErrUnauthorized = errors.New("unauthorized") - ErrForbidden = errors.New("forbidden") -) -``` - -### 4.2 Create `repository.go` - -Define the repository interface that the service layer will depend on: - -```go -package domain - -import "github.com/google/uuid" - -// ProductFilter holds query parameters for listing products -type ProductFilter struct { - Q string // Full-text search query - CategoryID *uuid.UUID // Filter by category - MinPrice *float64 // Minimum price filter - MaxPrice *float64 // Maximum price filter - Sort string // Sort order: price_asc, price_desc, newest, name_asc, name_desc - Page int // Page number (1-based) - Limit int // Items per page -} - -// ProductListResponse holds paginated product results -type ProductListResponse struct { - Products []Product - Total int64 - Page int - Limit int -} - -// ProductRepository defines the data access contract for products and categories -type ProductRepository interface { - // Product CRUD - CreateProduct(product *Product) (*Product, error) - GetProductByID(id uuid.UUID) (*Product, error) - ListProducts(filter ProductFilter) (*ProductListResponse, error) - UpdateProduct(product *Product) (*Product, error) - DeleteProduct(id uuid.UUID) error - - // Category operations - CreateCategory(category *Category) (*Category, error) - ListCategories() ([]Category, error) - GetCategoryByID(id uuid.UUID) (*Category, error) - GetCategoryBySlug(slug string) (*Category, error) -} -``` - -**Key Design Decisions:** -- `ProductFilter` uses pointers for optional filters to distinguish "not provided" from "zero value" -- `ListProducts` returns a struct (not slice + count) for cleaner API -- Default pagination: `Page=1`, `Limit=20`, `Sort="newest"` (handled by service layer) -- Categories are simple — no pagination needed (expected < 1000 categories) - -### 4.3 Create `cache.go` - -Define the cache interface: - -```go -package domain - -import "context" - -// ProductCache defines the caching contract for product data -type ProductCache interface { - // Product detail cache - GetProduct(ctx context.Context, id string) (*Product, error) - SetProduct(ctx context.Context, product *Product) error - DeleteProduct(ctx context.Context, id string) error - - // Product list cache (paginated results) - GetProductList(ctx context.Context, cacheKey string) (*ProductListResponse, error) - SetProductList(ctx context.Context, cacheKey string, response *ProductListResponse) error - InvalidateProductList(ctx context.Context) error - - // Utility - ClearAll(ctx context.Context) error -} -``` - -**Key Design Decisions:** -- Separate methods for product detail vs list caching (different TTLs and invalidation patterns) -- `InvalidateProductList` deletes all `products:list:*` keys (wildcard invalidation) -- Context-aware for timeout/cancellation support - -### 4.4 Update `product.go` (if needed) - -Current models are complete. Verify alignment with Technical Plan §5 database schema: - -| Technical Plan Schema | Current Model | Alignment | -|---|---|---| -| `products.id UUID` | `Product.ID uuid.UUID` | ✅ | -| `products.category_id UUID` | `Product.CategoryID uuid.UUID` | ✅ | -| `products.name VARCHAR(500)` | `Product.Name string` | ⚠️ Update to `varchar(500)` | -| `products.description TEXT` | `Product.Description string` | ✅ | -| `products.price DECIMAL(12,2)` | `Product.Price float64` | ⚠️ Consider `decimal.Decimal` for precision | -| `products.image_url TEXT` | `Product.ImageURL string` | ✅ | -| `products.search_vector TSVECTOR` | `Product.SearchVector string` | ⚠️ GORM tsvector support needs verification | -| `products.is_active BOOLEAN` | `Product.IsActive bool` | ✅ | -| `categories.parent_id UUID` | `Category.ParentID` | ❌ Missing — add to Category model | - -**Required update to `Category` model:** - -```go -type Category struct { - ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` - Name string `json:"name" gorm:"type:varchar(255);not null"` - Slug string `json:"slug" gorm:"type:varchar(255);not null;uniqueIndex"` - ParentID *uuid.UUID `json:"parent_id,omitempty" gorm:"type:uuid"` // ADD THIS - CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` -} -``` - -### 4.5 Create `events.go` (Optional — for Kafka publishing) - -Define event types the service will publish: - -```go -package domain - -// EventPublisher defines the contract for publishing domain events -type EventPublisher interface { - Publish(ctx context.Context, topic string, payload any) error -} - -// Product event topic constants -const ( - TopicProductCreated = "product.created" - TopicProductUpdated = "product.updated" - TopicProductDeleted = "product.deleted" -) -``` - ---- - -## 5. Layer 2: Repository - -**Location:** `internal/repository/product_repository.go` -**Purpose:** Implement `domain.ProductRepository` using GORM. All database logic lives here. - -### 5.1 Repository Structure - -```go -package repository - -import ( - "auron/product-service/internal/domain" - "gorm.io/gorm" -) - -type ProductRepository struct { - db *gorm.DB -} - -func NewProductRepository(db *gorm.DB) domain.ProductRepository { - return &ProductRepository{db: db} -} -``` - -### 5.2 Implementation Tasks - -| Method | Implementation Details | -|---|---| -| `CreateProduct` | `db.Create(product)`, preload category after create | -| `GetProductByID` | `db.Where("id = ?", id).Preload("Category").First(&product)`, return `ErrProductNotFound` on `gorm.ErrRecordNotFound` | -| `ListProducts` | See detailed implementation below | -| `UpdateProduct` | `db.Save(product)`, update `updated_at` via GORM | -| `DeleteProduct` | Soft delete or hard delete (`db.Delete(&Product{}, id)`), also delete inventory row | -| `CreateCategory` | `db.Create(category)`, check slug uniqueness | -| `ListCategories` | `db.Order("name ASC").Find(&categories)` | -| `GetCategoryByID` | `db.First(&category, id)` | -| `GetCategoryBySlug` | `db.Where("slug = ?", slug).First(&category)` | - -### 5.3 `ListProducts` Detailed Implementation - -```go -func (r *ProductRepository) ListProducts(filter domain.ProductFilter) (*domain.ProductListResponse, error) { - query := r.db.Model(&domain.Product{}).Where("is_active = ?", true) - - // Filter by category - if filter.CategoryID != nil { - query = query.Where("category_id = ?", *filter.CategoryID) - } - - // Price range filters - if filter.MinPrice != nil { - query = query.Where("price >= ?", *filter.MinPrice) - } - if filter.MaxPrice != nil { - query = query.Where("price <= ?", *filter.MaxPrice) - } - - // Full-text search - if filter.Q != "" { - query = query.Where("search_vector @@ plainto_tsquery('english', ?)", filter.Q) - } - - // Count total (before pagination) - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, err - } - - // Apply sorting - query = r.applySort(query, filter.Sort) - - // Apply pagination - offset := (filter.Page - 1) * filter.Limit - query = query.Offset(offset).Limit(filter.Limit) - - // Execute query with category preload - var products []domain.Product - if err := query.Preload("Category").Find(&products).Error; err != nil { - return nil, err - } - - return &domain.ProductListResponse{ - Products: products, - Total: total, - Page: filter.Page, - Limit: filter.Limit, - }, nil -} - -func (r *ProductRepository) applySort(query *gorm.DB, sort string) *gorm.DB { - switch sort { - case "price_asc": - return query.Order("price ASC") - case "price_desc": - return query.Order("price DESC") - case "newest": - return query.Order("created_at DESC") - case "name_asc": - return query.Order("name ASC") - case "name_desc": - return query.Order("name DESC") - default: - return query.Order("created_at DESC") // Default: newest first - } -} -``` - -### 5.4 Key Considerations - -- **tsvector queries**: Use raw SQL via `db.Where()` since GORM doesn't natively support full-text search -- **Category preload**: Always preload `Category` relation to avoid N+1 queries -- **Pagination safety**: Validate `offset` doesn't go negative (service layer handles this) -- **Transaction support**: `DeleteProduct` may need to delete related inventory row — use `db.Transaction()` - ---- - -## 6. Layer 3: Cache - -**Location:** `internal/cache/product_cache.go` -**Purpose:** Implement `domain.ProductCache` using Redis. Follow the caching strategy from Technical Plan §7. - -### 6.1 Cache Structure - -```go -package cache - -import ( - "auron/product-service/internal/domain" - "context" - "encoding/json" - "fmt" - "time" - - "github.com/redis/go-redis/v9" -) - -const ( - productDetailPrefix = "product:" - productListPrefix = "products:list:" - cacheTTL = 5 * time.Minute -) - -type ProductCache struct { - redis *redis.Client -} - -func NewProductCache(redisClient *redis.Client) domain.ProductCache { - return &ProductCache{redis: redisClient} -} -``` - -### 6.2 Implementation Tasks - -| Method | Key Pattern | TTL | Notes | -|---|---|---|---| -| `GetProduct` | `product:{id}` | 5 min | JSON serialize/deserialize | -| `SetProduct` | `product:{id}` | 5 min | Marshal product to JSON | -| `DeleteProduct` | `product:{id}` | — | `DEL` command | -| `GetProductList` | `products:list:{hash}` | 5 min | Hash of filter params | -| `SetProductList` | `products:list:{hash}` | 5 min | Marshal response to JSON | -| `InvalidateProductList` | `products:list:*` | — | SCAN + DEL (pattern match) | - -### 6.3 Cache Key Generation - -```go -// GenerateCacheKey creates a deterministic cache key from filter params -func GenerateCacheKey(filter domain.ProductFilter) string { - // Create a hash from filter params for list caching - hash := fmt.Sprintf("%s_%s_%s_%s_%d_%d", - filter.Q, - filter.CategoryID.String(), - fmt.Sprintf("%.2f", filter.MinPrice), - fmt.Sprintf("%.2f", filter.MaxPrice), - filter.Page, - filter.Limit, - ) - return productListPrefix + hash -} -``` - -### 6.4 Pattern Invalidation (SCAN + DEL) - -```go -func (c *ProductCache) InvalidateProductList(ctx context.Context) error { - var cursor uint64 - pattern := productListPrefix + "*" - - for { - keys, nextCursor, err := c.redis.Scan(ctx, cursor, pattern, 100).Result() - if err != nil { - return err - } - - if len(keys) > 0 { - if err := c.redis.Del(ctx, keys...).Err(); err != nil { - return err - } - } - - cursor = nextCursor - if cursor == 0 { - break - } - } - - return nil -} -``` - -### 6.5 Key Considerations - -- Use `SCAN` instead of `KEYS` for production safety (non-blocking) -- JSON serialization for complex structs (`ProductListResponse`) -- TTL is consistent: 5 minutes for all product cache entries -- Cache misses return `redis.Nil` — service layer translates to `domain.ErrProductNotFound` - ---- - -## 7. Layer 4: Service - -**Location:** `internal/service/product_service.go` -**Purpose:** Business logic layer. Orchestrates repository + cache + event publishing. - -### 7.1 Service Structure - -```go -package service - -import ( - "auron/product-service/internal/domain" - "context" -) - -type ProductService struct { - repo domain.ProductRepository - cache domain.ProductCache - publisher domain.EventPublisher -} - -func NewProductService( - repo domain.ProductRepository, - cache domain.ProductCache, - publisher domain.EventPublisher, -) domain.ProductService { - return &ProductService{ - repo: repo, - cache: cache, - publisher: publisher, - } -} -``` - -### 7.2 Service Interface (`domain/service.go`) - -```go -package domain - -import "github.com/google/uuid" - -type ProductService interface { - // Product operations - CreateProduct(req *ProductRequest) (*Product, error) - GetProduct(id uuid.UUID) (*Product, error) - ListProducts(filter ProductFilter) (*ProductListResponse, error) - UpdateProduct(id uuid.UUID, req *ProductRequest) (*Product, error) - DeleteProduct(id uuid.UUID) error - - // Category operations - CreateCategory(req *CategoryRequest) (*Category, error) - ListCategories() ([]Category, error) -} -``` - -### 7.3 Implementation Tasks - -| Method | Business Logic | -|---|---| -| `CreateProduct` | Validate request → check category exists → create product → create inventory row (qty=0) → cache product → invalidate list cache → publish `product.created` event | -| `GetProduct` | **Cache-first**: check cache → if miss, query repo → cache result → return | -| `ListProducts` | **Cache-first**: generate cache key → check cache → if miss, query repo → cache result → return | -| `UpdateProduct` | Validate request → check product exists → check category exists → update → delete product cache → invalidate list cache → publish `product.updated` | -| `DeleteProduct` | Check product exists → delete from repo → delete cache → invalidate list cache → publish `product.deleted` | -| `CreateCategory` | Validate request → check slug uniqueness → create category | -| `ListCategories` | Direct repo call (no caching needed for small dataset) | - -### 7.4 Default Pagination & Validation - -```go -func normalizeFilter(filter *domain.ProductFilter) error { - // Default page - if filter.Page < 1 { - filter.Page = 1 - } - - // Default limit - if filter.Limit < 1 { - filter.Limit = 20 - } - if filter.Limit > 100 { - filter.Limit = 100 - } - - // Default sort - if filter.Sort == "" { - filter.Sort = "newest" - } - - // Validate sort - validSorts := map[string]bool{ - "price_asc": true, "price_desc": true, - "newest": true, "name_asc": true, "name_desc": true, - } - if !validSorts[filter.Sort] { - return domain.ErrInvalidSortParam - } - - return nil -} -``` - -### 7.5 Cache-First Read Pattern - -```go -func (s *ProductService) GetProduct(id uuid.UUID) (*domain.Product, error) { - ctx := context.Background() - - // Try cache first - if product, err := s.cache.GetProduct(ctx, id.String()); err == nil { - return product, nil - } - - // Cache miss → query repository - product, err := s.repo.GetProductByID(id) - if err != nil { - return nil, err - } - - // Populate cache (non-blocking — log errors, don't fail the request) - if err := s.cache.SetProduct(ctx, product); err != nil { - slog.Warn("failed to cache product", "product_id", id, "error", err) - } - - return product, nil -} -``` - -### 7.6 Write Path with Cache Invalidation - -```go -func (s *ProductService) UpdateProduct(id uuid.UUID, req *domain.ProductRequest) (*domain.Product, error) { - ctx := context.Background() - - // Verify product exists - existing, err := s.repo.GetProductByID(id) - if err != nil { - return nil, err - } - - // Verify category exists (if changed) - if req.CategoryID != existing.CategoryID { - if _, err := s.repo.GetCategoryByID(req.CategoryID); err != nil { - return nil, domain.ErrCategoryNotFound - } - } - - // Update fields - existing.Name = req.Name - existing.Description = req.Description - existing.Price = req.Price - existing.ImageURL = req.ImageURL - existing.CategoryID = req.CategoryID - if req.IsActive != nil { - existing.IsActive = *req.IsActive - } - - updated, err := s.repo.UpdateProduct(existing) - if err != nil { - return nil, err - } - - // Invalidate caches - _ = s.cache.DeleteProduct(ctx, id.String()) - _ = s.cache.InvalidateProductList(ctx) - - // Publish event (non-blocking) - go func() { - _ = s.publisher.Publish(context.Background(), domain.TopicProductUpdated, updated) - }() - - return updated, nil -} -``` - -### 7.7 Key Considerations - -- **Cache failures are non-fatal**: If cache set/delete fails, log warning but continue -- **Event publishing is async**: Use goroutine to avoid blocking HTTP response -- **Transaction safety**: Product creation needs product + inventory rows in same transaction (repo handles this) -- **Category validation**: Always verify category exists before product create/update - ---- - -## 8. Layer 5: Handler - -**Location:** `internal/handler/product_handler.go` -**Purpose:** HTTP request/response handling. Thin layer — delegates to service, maps errors to HTTP status codes. - -### 8.1 Handler Structure - -```go -package handler - -import ( - "auron/product-service/internal/domain" - "net/http" - - "github.com/gin-gonic/gin" -) - -type ProductHandler struct { - service domain.ProductService -} - -func NewProductHandler(service domain.ProductService) *ProductHandler { - return &ProductHandler{service: service} -} -``` - -### 8.2 Handler Methods - -| HTTP Handler | Service Method | Success Status | Notes | -|---|---|---|---| -| `ListProducts` | `service.ListProducts(filter)` | 200 | Parse query params → filter | -| `GetProduct` | `service.GetProduct(id)` | 200 | Parse UUID from path param | -| `CreateProduct` | `service.CreateProduct(req)` | 201 | Bind JSON body → validate | -| `UpdateProduct` | `service.UpdateProduct(id, req)` | 200 | Bind JSON body → validate | -| `DeleteProduct` | `service.DeleteProduct(id)` | 200 | Return success message | -| `ListCategories` | `service.ListCategories()` | 200 | No params needed | -| `CreateCategory` | `service.CreateCategory(req)` | 201 | Bind JSON body → validate | - -### 8.3 `ListProducts` Handler Implementation - -```go -func (h *ProductHandler) ListProducts(c *gin.Context) { - filter, err := h.parseFilter(c) - if err != nil { - c.JSON(http.StatusBadRequest, domain.ErrorResponse{Error: err.Error()}) - return - } - - result, err := h.service.ListProducts(filter) - if err != nil { - h.handleServiceError(c, err) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": result.Products, - "meta": gin.H{ - "page": result.Page, - "limit": result.Limit, - "total": result.Total, - }, - }) -} - -func (h *ProductHandler) parseFilter(c *gin.Context) (domain.ProductFilter, error) { - filter := domain.ProductFilter{ - Q: c.Query("q"), - Sort: c.Query("sort"), - Page: parseIntOrDefault(c.Query("page"), 1), - Limit: parseIntOrDefault(c.Query("limit"), 20), - } - - // Parse optional UUID - if categoryID := c.Query("category_id"); categoryID != "" { - id, err := uuid.Parse(categoryID) - if err != nil { - return filter, fmt.Errorf("invalid category_id: %w", err) - } - filter.CategoryID = &id - } - - // Parse optional floats - if minPrice := c.Query("min_price"); minPrice != "" { - price, err := strconv.ParseFloat(minPrice, 64) - if err != nil { - return filter, fmt.Errorf("invalid min_price: %w", err) - } - filter.MinPrice = &price - } - - if maxPrice := c.Query("max_price"); maxPrice != "" { - price, err := strconv.ParseFloat(maxPrice, 64) - if err != nil { - return filter, fmt.Errorf("invalid max_price: %w", err) - } - filter.MaxPrice = &price - } - - return filter, nil -} -``` - -### 8.4 Error Mapping - -```go -func (h *ProductHandler) handleServiceError(c *gin.Context, err error) { - switch { - case errors.Is(err, domain.ErrProductNotFound): - c.JSON(http.StatusNotFound, domain.ErrorResponse{ - "success": false, - "error": err.Error(), - }) - case errors.Is(err, domain.ErrCategoryNotFound): - c.JSON(http.StatusBadRequest, domain.ErrorResponse{ - "success": false, - "error": err.Error(), - }) - case errors.Is(err, domain.ErrCategorySlugExists): - c.JSON(http.StatusConflict, domain.ErrorResponse{ - "success": false, - "error": err.Error(), - }) - case errors.Is(err, domain.ErrInvalidSortParam), - errors.Is(err, domain.ErrInvalidPageParam), - errors.Is(err, domain.ErrInvalidLimitParam): - c.JSON(http.StatusBadRequest, domain.ErrorResponse{ - "success": false, - "error": err.Error(), - }) - default: - c.JSON(http.StatusInternalServerError, domain.ErrorResponse{ - "success": false, - "error": "internal server error", - }) - } -} -``` - -### 8.5 Response Format (per Technical Plan §10) - -All responses follow the standard envelope: - -```json -// Success -{ - "success": true, - "data": { ... }, - "meta": { "page": 1, "limit": 20, "total": 100 } -} - -// Error -{ - "success": false, - "error": { - "code": "PRODUCT_NOT_FOUND", - "message": "product not found" - } -} -``` - -### 8.6 Key Considerations - -- **Keep handlers thin**: No business logic — only HTTP binding and error mapping -- **Validate at handler level**: Use Gin's `binding` tags for required fields -- **Parse UUIDs safely**: Return 400 for invalid UUIDs (don't let it reach service layer) -- **Consistent error format**: Match the API Gateway error response contract - ---- - -## 9. Layer 6: Route - -**Location:** `internal/route/product_route.go` -**Purpose:** Register routes with Gin engine. Apply middleware for auth/role checks. - -### 9.1 Route Registration - -```go -package route - -import ( - "auron/product-service/internal/handler" - - "github.com/gin-gonic/gin" -) - -func RegisterProductRoutes(router *gin.Engine, h *handler.ProductHandler) { - api := router.Group("/") - - // ── Public routes (no auth) ── - api.GET("/products", h.ListProducts) - api.GET("/products/:id", h.GetProduct) - api.GET("/categories", h.ListCategories) - - // ── Admin routes (auth + role check) ── - // Note: Admin middleware is applied by API Gateway. - // The service receives X-User-Role header from gateway. - admin := api.Group("/") - // admin.Use(middleware.RequireAdmin()) // Applied at gateway level - { - admin.POST("/products", h.CreateProduct) - admin.PUT("/products/:id", h.UpdateProduct) - admin.DELETE("/products/:id", h.DeleteProduct) - admin.POST("/categories", h.CreateCategory) - } -} -``` - -### 9.2 Route Mapping to API Gateway - -Per Technical Plan §4.1 routing table, the API Gateway proxies: - -| Gateway Route | Downstream Route | Auth | -|---|---|---| -| `GET /api/products` | `GET /products` | No | -| `GET /api/products/:id` | `GET /products/:id` | No | -| `POST /api/products` | `POST /products` | Yes (admin) | -| `PUT /api/products/:id` | `PUT /products/:id` | Yes (admin) | -| `DELETE /api/products/:id` | `DELETE /products/:id` | Yes (admin) | -| `GET /api/categories` | `GET /categories` | No | -| `POST /api/categories` | `POST /categories` | Yes (admin) | - -**Important:** The API Gateway handles JWT validation and admin role checking. The product service can trust the `X-User-Role` header forwarded by the gateway. - -### 9.3 Middleware Needs - -| Middleware | Applied By | Purpose | -|---|---|---| -| JWT validation | API Gateway | Verify access token | -| Admin role check | API Gateway | Check `role=admin` in JWT claims | -| Request ID | API Gateway | Inject `X-Request-ID` header | -| CORS | API Gateway | Handle cross-origin requests | -| Rate limiting | API Gateway | 100 req/min per IP | - -**Product service does NOT need its own auth middleware** — it relies on the API Gateway for all cross-cutting concerns. - ---- - -## 10. Layer 7: Bootstrap (Outer) - -**Location:** `main.go` + `cmd/` directory -**Purpose:** Wire all layers together. Load config, setup infrastructure, start server. - -### 10.1 File Structure - -``` -cmd/ -├── config.go # Configuration struct + loading -├── dotenv.go # .env file loading -├── infrastructure.go # Database + Redis setup -├── kafka.go # Kafka producer setup (optional) -├── run.go # Main orchestration (DI wiring) -└── server.go # Gin router setup + graceful shutdown -main.go # Entry point (calls cmd.Run()) -``` - -### 10.2 `config.go` - -```go -package cmd - -import "os" - -type Config struct { - Port string - DatabaseURL string - RedisURL string - KafkaBrokers string - Environment string // dev, staging, prod -} - -func loadConfig() *Config { - return &Config{ - Port: getEnv("PORT", "8082"), - DatabaseURL: getEnv("DATABASE_URL", "postgres://auron:auron_pass@products-db:5433/products_db?sslmode=disable"), - RedisURL: getEnv("REDIS_URL", "redis://redis:6379/0"), - KafkaBrokers: getEnv("KAFKA_BROKERS", "kafka:29092"), - Environment: getEnv("ENVIRONMENT", "dev"), - } -} - -func getEnv(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} -``` - -### 10.3 `infrastructure.go` - -```go -package cmd - -import ( - "gorm.io/driver/postgres" - "gorm.io/gorm" - "log" - - "github.com/redis/go-redis/v9" -) - -func setupDatabase(databaseURL string) (*gorm.DB, error) { - db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) - if err != nil { - return nil, err - } - - sqlDB, err := db.DB() - if err != nil { - return nil, err - } - - sqlDB.SetMaxIdleConns(10) - sqlDB.SetMaxOpenConns(100) - - return db, nil -} - -func setupRedis(redisURL string) (*redis.Client, error) { - opt, err := redis.ParseURL(redisURL) - if err != nil { - return nil, err - } - - client := redis.NewClient(opt) - return client, nil -} -``` - -### 10.4 `run.go` (DI Wiring) - -```go -package cmd - -import ( - "auron/product-service/internal/cache" - "auron/product-service/internal/handler" - "auron/product-service/internal/repository" - "auron/product-service/internal/service" - "fmt" - "log" -) - -func Run() { - cfg := loadConfig() - - // Setup infrastructure - db, err := setupDatabase(cfg.DatabaseURL) - if err != nil { - log.Fatalf("Failed to connect to database: %v", err) - } - - redisClient, err := setupRedis(cfg.RedisURL) - if err != nil { - log.Fatalf("Failed to connect to Redis: %v", err) - } - - // Run migrations (AutoMigrate for development) - if err := runMigrations(db); err != nil { - log.Fatalf("Failed to run migrations: %v", err) - } - - // Setup Kafka producer (optional — can be nil for initial implementation) - publisher := setupKafkaPublisher(cfg.KafkaBrokers) - - // Wire dependencies (Inner → Outer) - repo := repository.NewProductRepository(db) - cache := cache.NewProductCache(redisClient) - svc := service.NewProductService(repo, cache, publisher) - h := handler.NewProductHandler(svc) - - // Setup router and start server - router := setupRouter(h) - - addr := fmt.Sprintf(":%s", cfg.Port) - log.Printf("Starting Product Service on %s", addr) - if err := router.Run(addr); err != nil { - log.Fatalf("Failed to start server: %v", err) - } -} -``` - -### 10.5 `server.go` - -```go -package cmd - -import ( - "auron/product-service/internal/handler" - "auron/product-service/internal/route" - "time" - - "github.com/gin-gonic/gin" -) - -func setupRouter(h *handler.ProductHandler) *gin.Engine { - gin.SetMode(gin.ReleaseMode) - router := gin.New() - - // Global middleware - router.Use(gin.Logger()) - router.Use(gin.Recovery()) - - // Health check - router.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ - "status": "healthy", - "service": "product-service", - "timestamp": time.Now().UTC(), - }) - }) - - // Prometheus metrics (stub — implement later) - router.GET("/metrics", func(c *gin.Context) { - c.String(200, "# Prometheus metrics endpoint\n") - }) - - // Register product routes - route.RegisterProductRoutes(router, h) - - return router -} -``` - -### 10.6 `main.go` - -```go -package main - -import "auron/product-service/cmd" - -func main() { - cmd.Run() -} -``` - -### 10.7 Database Migration - -```go -func runMigrations(db *gorm.DB) error { - return db.AutoMigrate( - &domain.Product{}, - &domain.Category{}, - &domain.Inventory{}, - ) -} -``` - -**Note:** AutoMigrate is suitable for development. For production, use `golang-migrate` with SQL migration files. - -### 10.8 tsvector Index Bootstrapping - -```go -func bootstrapSearchIndex(db *gorm.DB) error { - // Create tsvector column if not exists - db.Exec("ALTER TABLE products ADD COLUMN IF NOT EXISTS search_vector tsvector") - - // Create GIN index - db.Exec("CREATE INDEX IF NOT EXISTS products_search_idx ON products USING GIN(search_vector)") - - // Populate search_vector for existing records - db.Exec(` - UPDATE products SET search_vector = to_tsvector('english', name || ' ' || COALESCE(description, '')) - WHERE search_vector IS NULL OR search_vector = '' - `) - - // Create trigger to auto-update search_vector on product changes - db.Exec(` - CREATE OR REPLACE FUNCTION products_search_vector_trigger() RETURNS trigger AS $$ - BEGIN - NEW.search_vector := to_tsvector('english', NEW.name || ' ' || COALESCE(NEW.description, '')); - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - DROP TRIGGER IF EXISTS products_search_vector_update ON products; - CREATE TRIGGER products_search_vector_update - BEFORE INSERT OR UPDATE ON products - FOR EACH ROW EXECUTE FUNCTION products_search_vector_trigger(); - `) - - return nil -} -``` - -Call this in `runMigrations()` after `AutoMigrate`. - ---- - -## 11. Database & Migrations - -### 11.1 Tables (from Technical Plan §5) - -**categories:** -```sql -CREATE TABLE categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - slug VARCHAR(255) UNIQUE NOT NULL, - parent_id UUID REFERENCES categories(id), - created_at TIMESTAMP DEFAULT NOW() -); -``` - -**products:** -```sql -CREATE TABLE products ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - category_id UUID REFERENCES categories(id), - name VARCHAR(500) NOT NULL, - description TEXT, - price DECIMAL(12, 2) NOT NULL, - image_url TEXT, - search_vector TSVECTOR, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - -CREATE INDEX idx_products_category ON products(category_id); -CREATE INDEX idx_products_search ON products USING GIN(search_vector); -CREATE INDEX idx_products_price ON products(price); -``` - -**inventory:** -```sql -CREATE TABLE inventory ( - product_id UUID PRIMARY KEY REFERENCES products(id), - total_quantity INTEGER NOT NULL DEFAULT 0, - reserved_quantity INTEGER NOT NULL DEFAULT 0, - version INTEGER NOT NULL DEFAULT 0, - updated_at TIMESTAMP DEFAULT NOW() -); -``` - -### 11.2 Migration Strategy - -**Development:** GORM `AutoMigrate` (sufficient for local dev) - -**Production:** SQL migration files via `golang-migrate` - -``` -migrations/ -├── 001_create_categories.up.sql -├── 001_create_categories.down.sql -├── 002_create_products.up.sql -├── 002_create_products.down.sql -├── 003_create_inventory.up.sql -├── 003_create_inventory.down.sql -└── 004_create_search_index.up.sql -``` - ---- - -## 12. Kafka Integration - -### 12.1 Events to Publish - -| Event | Topic | When | Key | -|---|---|---|---| -| Product Created | `product.created` | After successful product creation | `product_id` | -| Product Updated | `product.updated` | After successful product update | `product_id` | -| Product Deleted | `product.deleted` | After successful product deletion | `product_id` | - -### 12.2 Event Payload Structure - -```json -{ - "event_id": "uuid", - "event_type": "product.created", - "timestamp": "2025-01-01T00:00:00Z", - "payload": { - "product_id": "uuid", - "name": "Laptop Pro 16\"", - "category_id": "uuid", - "price": 1299.99, - "is_active": true - } -} -``` - -### 12.3 Integration with Shared Library - -Use `shared/kafka/producer.go`: - -```go -import "github.com/auron/shared/kafka" - -func setupKafkaPublisher(brokers string) domain.EventPublisher { - if brokers == "" { - return &noopPublisher{} // Silent no-op for dev - } - - return kafka.NewProducer(&kafka.ProducerConfig{ - Brokers: strings.Split(brokers, ","), - Topic: "product.created", // Default topic - }) -} -``` - -**Note:** Kafka integration is **optional for initial implementation**. The service should work without Kafka (use a no-op publisher for dev). - ---- - -## 13. Configuration & Environment - -### 13.1 `.env.example` - -```env -# Product Service Configuration -PORT=8082 -DATABASE_URL=postgres://auron:auron_pass@products-db:5433/products_db?sslmode=disable -REDIS_URL=redis://redis:6379/0 -KAFKA_BROKERS=kafka:29092 -ENVIRONMENT=dev - -# Cache Settings -CACHE_TTL=5m -``` - -### 13.2 Environment Variables - -| Variable | Required | Default | Description | -|---|---|---|---| -| `PORT` | No | `8082` | HTTP port | -| `DATABASE_URL` | Yes | — | PostgreSQL connection string | -| `REDIS_URL` | Yes | — | Redis connection string | -| `KAFKA_BROKERS` | No | — | Comma-separated Kafka brokers | -| `ENVIRONMENT` | No | `dev` | Environment name (dev/staging/prod) | -| `CACHE_TTL` | No | `5m` | Cache time-to-live duration | - ---- - -## 14. Implementation Checklist - -### Phase 1: Foundation (Domain Layer) - -- [ ] Update `internal/domain/errors.go` (expand error set) -- [ ] Create `internal/domain/repository.go` (ProductRepository interface + ProductFilter) -- [ ] Create `internal/domain/service.go` (ProductService interface) -- [ ] Create `internal/domain/cache.go` (ProductCache interface) -- [ ] Create `internal/domain/events.go` (EventPublisher interface + topic constants) -- [ ] Update `internal/domain/product.go` (add Category.ParentID field) - -### Phase 2: Data Access (Repository Layer) - -- [ ] Create `internal/repository/product_repository.go` - - [ ] `CreateProduct` (with inventory row creation) - - [ ] `GetProductByID` (with Category preload) - - [ ] `ListProducts` (with filtering, sorting, pagination, full-text search) - - [ ] `UpdateProduct` - - [ ] `DeleteProduct` - - [ ] `CreateCategory` - - [ ] `ListCategories` - - [ ] `GetCategoryByID` - - [ ] `GetCategoryBySlug` - -### Phase 3: Caching (Cache Layer) - -- [ ] Create `internal/cache/product_cache.go` - - [ ] `GetProduct` / `SetProduct` / `DeleteProduct` - - [ ] `GetProductList` / `SetProductList` - - [ ] `InvalidateProductList` (SCAN + DEL pattern) - - [ ] Cache key generation helper - -### Phase 4: Business Logic (Service Layer) - -- [ ] Create `internal/service/product_service.go` - - [ ] `CreateProduct` (validate → create → cache → publish event) - - [ ] `GetProduct` (cache-first read) - - [ ] `ListProducts` (cache-first read with filter hashing) - - [ ] `UpdateProduct` (validate → update → invalidate cache → publish event) - - [ ] `DeleteProduct` (delete → invalidate cache → publish event) - - [ ] `CreateCategory` (validate → create) - - [ ] `ListCategories` (direct repo call) - - [ ] `normalizeFilter` helper (defaults + validation) - -### Phase 5: HTTP Interface (Handler Layer) - -- [ ] Create `internal/handler/product_handler.go` - - [ ] `ListProducts` (parse query params → filter → respond) - - [ ] `GetProduct` (parse UUID → respond) - - [ ] `CreateProduct` (bind JSON → validate → respond) - - [ ] `UpdateProduct` (bind JSON → validate → respond) - - [ ] `DeleteProduct` (parse UUID → respond) - - [ ] `ListCategories` (respond) - - [ ] `CreateCategory` (bind JSON → validate → respond) - - [ ] `handleServiceError` (error → HTTP status mapping) - - [ ] `parseFilter` helper (query param parsing) - -### Phase 6: Routing (Route Layer) - -- [ ] Create `internal/route/product_route.go` - - [ ] Register public GET routes - - [ ] Register admin POST/PUT/DELETE routes - - [ ] Configure middleware (gateway-forwarded headers) - -### Phase 7: Bootstrap (Outer Layer) - -- [ ] Create `cmd/config.go` (config struct + loading) -- [ ] Create `cmd/dotenv.go` (.env file loading) -- [ ] Create `cmd/infrastructure.go` (DB + Redis setup) -- [ ] Create `cmd/kafka.go` (Kafka publisher setup) -- [ ] Create `cmd/run.go` (DI wiring) -- [ ] Create `cmd/server.go` (Gin router + health check) -- [ ] Create `main.go` (entry point) -- [ ] Add tsvector index bootstrapping -- [ ] Add graceful shutdown - -### Phase 8: Configuration & Deployment - -- [ ] Create `.env.example` -- [ ] Create `Dockerfile` (multi-stage: golang builder → alpine runner) -- [ ] Update `go.mod` with required dependencies -- [ ] Verify docker-compose.yml integration (port 8082, health check) - -### Phase 9: Testing & Validation - -- [ ] Write unit tests for service layer (`service/product_service_test.go`) -- [ ] Write unit tests for repository layer (with test DB) -- [ ] Write integration tests (full HTTP flow with real DB + Redis) -- [ ] Smoke test: `curl http://localhost:8082/health` -- [ ] Smoke test: Create category → Create product → List products → Get product -- [ ] Cache validation test: Update product → verify cache miss on next GET -- [ ] `go test ./...` passes with 0 failures - ---- - -## 15. File Structure - -### Final Directory Layout - -``` -services/product-service/ -├── main.go # Entry point -├── go.mod # Go module definition -├── go.sum # Dependency lock file -├── Dockerfile # Multi-stage build -├── .env.example # Environment template -│ -├── cmd/ -│ ├── config.go # Configuration loading -│ ├── dotenv.go # .env file loading -│ ├── infrastructure.go # Database + Redis setup -│ ├── kafka.go # Kafka producer setup -│ ├── run.go # Main orchestration (DI wiring) -│ └── server.go # Gin router + health check -│ -├── internal/ -│ ├── domain/ -│ │ ├── product.go # Entities (Product, Category, Inventory) + DTOs ✅ -│ │ ├── errors.go # Error types ⚠️ -│ │ ├── repository.go # ProductRepository interface ❌ -│ │ ├── service.go # ProductService interface ❌ -│ │ ├── cache.go # ProductCache interface ❌ -│ │ └── events.go # EventPublisher interface + topic constants ❌ -│ │ -│ ├── repository/ -│ │ └── product_repository.go # GORM implementation ❌ -│ │ -│ ├── cache/ -│ │ └── product_cache.go # Redis implementation ❌ -│ │ -│ ├── service/ -│ │ └── product_service.go # Business logic ❌ -│ │ -│ ├── handler/ -│ │ └── product_handler.go # HTTP handlers ❌ -│ │ -│ ├── route/ -│ │ └── product_route.go # Route registration ❌ -│ │ -│ ├── middleware/ # (Optional — for future use) -│ │ -│ └── events/ # (Optional — for Kafka event structs) -│ -└── migrations/ # (Optional — for production migrations) - ├── 001_create_categories.up.sql - └── ... -``` - -**Legend:** -- ✅ = Already exists and complete -- ⚠️ = Exists but needs updates -- ❌ = Needs to be created - ---- - -## Appendix A: Key Design Decisions - -| Decision | Rationale | -|---|---| -| Cache-first reads | Reduces DB load for read-heavy product catalog traffic | -| Separate cache vs repo interfaces | Single Responsibility — each layer has one concern | -| No auth middleware in service | API Gateway handles all cross-cutting concerns | -| Async event publishing | Don't block HTTP response on Kafka availability | -| AutoMigrate for dev, SQL migrations for prod | Fast iteration in dev, auditable changes in prod | -| tsvector trigger on INSERT/UPDATE | Keeps search index in sync without application logic | -| SCAN for cache invalidation | Production-safe (non-blocking) alternative to KEYS | -| Inventory row created with product | Ensures inventory record exists before inventory-service manages it | - -## Appendix B: Dependencies - -From existing `go.mod`: - -```go -require ( - github.com/gin-gonic/gin v1.9.1 - github.com/google/uuid v1.6.0 - github.com/redis/go-redis/v9 v9.4.0 - github.com/segmentio/kafka-go v0.4.47 - gorm.io/driver/postgres v1.5.4 - gorm.io/gorm v1.25.5 -) - -replace github.com/auron/shared => ../../shared -``` - -## Appendix C: Health Check Configuration - -Per docker-compose.yml health check pattern: - -```yaml -product-service: - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8082/health"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s -``` - -The `/health` endpoint returns: - -```json -{ - "status": "healthy", - "service": "product-service", - "timestamp": "2025-01-01T00:00:00Z" -} -``` - ---- - -*Plan created: 2025-04-14* -*Based on: ecommerce-technical-plan.md §4.3, §5, §7, §10* \ No newline at end of file diff --git a/services/product-service/db/004_create_search_index.up.sql b/services/product-service/db/004_create_search_index.up.sql deleted file mode 100644 index 28953ae..0000000 --- a/services/product-service/db/004_create_search_index.up.sql +++ /dev/null @@ -1,42 +0,0 @@ --- Migration: 004_create_search_index --- Purpose: Setup PostgreSQL full-text search with tsvector and automatic triggers - --- Add search_vector column if it doesn't exist (GORM may have created it) -ALTER TABLE products ADD COLUMN IF NOT EXISTS search_vector tsvector; - --- Create GIN index for full-text search -CREATE INDEX IF NOT EXISTS idx_products_search ON products USING GIN(search_vector); - --- Create function to update search_vector on product changes -CREATE OR REPLACE FUNCTION products_search_vector_trigger() RETURNS trigger AS $$ -BEGIN - NEW.search_vector := to_tsvector('english', COALESCE(NEW.name, '') || ' ' || COALESCE(NEW.description, '')); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Drop existing trigger if it exists -DROP TRIGGER IF EXISTS products_search_vector_update ON products; - --- Create trigger to auto-populate search_vector on INSERT/UPDATE -CREATE TRIGGER products_search_vector_update - BEFORE INSERT OR UPDATE ON products - FOR EACH ROW - EXECUTE FUNCTION products_search_vector_trigger(); - --- Populate search_vector for existing records -UPDATE products -SET search_vector = to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(description, '')) -WHERE search_vector IS NULL OR search_vector = ''; - --- Add index on category_id for faster filtering -CREATE INDEX IF NOT EXISTS idx_products_category_id ON products(category_id); - --- Add index on price for range queries -CREATE INDEX IF NOT EXISTS idx_products_price ON products(price); - --- Add index on is_active for filtering -CREATE INDEX IF NOT EXISTS idx_products_is_active ON products(is_active); - --- Add index on created_at for sorting -CREATE INDEX IF NOT EXISTS idx_products_created_at ON products(created_at DESC); diff --git a/shared/events/types.go b/shared/events/types.go deleted file mode 100644 index 6cd5bcc..0000000 --- a/shared/events/types.go +++ /dev/null @@ -1,335 +0,0 @@ -// Package events defines shared event types for Kafka message handling across all Auron services. -package events - -import ( - "time" - - "github.com/google/uuid" -) - -// ============================================================ -// EVENT STRUCTURES -// ============================================================ - -// Event is the base event structure for all Kafka messages -type Event struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - Timestamp time.Time `json:"timestamp"` - Payload interface{} `json:"payload"` -} - -// BaseEvent creates a new event with the given type and payload -func BaseEvent(eventType string, payload interface{}) Event { - return Event{ - EventID: uuid.New().String(), - EventType: eventType, - Timestamp: time.Now().UTC(), - Payload: payload, - } -} - -// ============================================================ -// USER EVENTS -// ============================================================ - -// UserRegisteredPayload is the payload for user.registered events -type UserRegisteredPayload struct { - UserID string `json:"user_id"` - Email string `json:"email"` - Name string `json:"name"` - CreatedAt time.Time `json:"created_at"` -} - -// UserRegistered represents a new user registration event -type UserRegistered struct { - Event - Payload UserRegisteredPayload `json:"payload"` -} - -// NewUserRegistered creates a new user registered event -func NewUserRegistered(userID, email, name string) UserRegistered { - return UserRegistered{ - Event: BaseEvent("user.registered", nil), - Payload: UserRegisteredPayload{ - UserID: userID, - Email: email, - Name: name, - CreatedAt: time.Now().UTC(), - }, - } -} - -// ============================================================ -// ORDER EVENTS -// ============================================================ - -// OrderItem represents an item in an order -type OrderItem struct { - ProductID string `json:"product_id"` - Name string `json:"name"` - Price float64 `json:"price"` - Quantity int `json:"quantity"` - Subtotal float64 `json:"subtotal"` -} - -// ShippingAddress represents a shipping address -type ShippingAddress struct { - Street string `json:"street"` - City string `json:"city"` - State string `json:"state"` - Country string `json:"country"` - PostalCode string `json:"postal_code"` -} - -// OrderCreatedPayload is the payload for order.created events -type OrderCreatedPayload struct { - OrderID string `json:"order_id"` - UserID string `json:"user_id"` - UserEmail string `json:"user_email"` - Items []OrderItem `json:"items"` - TotalAmount float64 `json:"total_amount"` - ShippingAddress ShippingAddress `json:"shipping_address"` - CreatedAt time.Time `json:"created_at"` -} - -// OrderCreated represents an order creation event -type OrderCreated struct { - Event - Payload OrderCreatedPayload `json:"payload"` -} - -// NewOrderCreated creates a new order created event -func NewOrderCreated(orderID, userID, userEmail string, items []OrderItem, total float64, address ShippingAddress) OrderCreated { - return OrderCreated{ - Event: BaseEvent("order.created", nil), - Payload: OrderCreatedPayload{ - OrderID: orderID, - UserID: userID, - UserEmail: userEmail, - Items: items, - TotalAmount: total, - ShippingAddress: address, - CreatedAt: time.Now().UTC(), - }, - } -} - -// OrderCancelledPayload is the payload for order.cancelled events -type OrderCancelledPayload struct { - OrderID string `json:"order_id"` - UserID string `json:"user_id"` - Reason string `json:"reason"` - CancelledAt time.Time `json:"cancelled_at"` -} - -// OrderCancelled represents an order cancellation event -type OrderCancelled struct { - Event - Payload OrderCancelledPayload `json:"payload"` -} - -// ============================================================ -// PAYMENT EVENTS -// ============================================================ - -// PaymentProcessedPayload is the payload for payment.processed events -type PaymentProcessedPayload struct { - OrderID string `json:"order_id"` - PaymentID string `json:"payment_id"` - UserID string `json:"user_id"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - StripePaymentIntentID string `json:"stripe_payment_intent_id"` - ProcessedAt time.Time `json:"processed_at"` -} - -// PaymentProcessed represents a successful payment event -type PaymentProcessed struct { - Event - Payload PaymentProcessedPayload `json:"payload"` -} - -// NewPaymentProcessed creates a new payment processed event -func NewPaymentProcessed(orderID, paymentID, userID string, amount float64, currency, stripeID string) PaymentProcessed { - return PaymentProcessed{ - Event: BaseEvent("payment.processed", nil), - Payload: PaymentProcessedPayload{ - OrderID: orderID, - PaymentID: paymentID, - UserID: userID, - Amount: amount, - Currency: currency, - StripePaymentIntentID: stripeID, - ProcessedAt: time.Now().UTC(), - }, - } -} - -// PaymentFailedPayload is the payload for payment.failed events -type PaymentFailedPayload struct { - OrderID string `json:"order_id"` - PaymentID string `json:"payment_id"` - UserID string `json:"user_id"` - Amount float64 `json:"amount"` - Reason string `json:"reason"` - FailedAt time.Time `json:"failed_at"` -} - -// PaymentFailed represents a failed payment event -type PaymentFailed struct { - Event - Payload PaymentFailedPayload `json:"payload"` -} - -// NewPaymentFailed creates a new payment failed event -func NewPaymentFailed(orderID, paymentID, userID string, amount float64, reason string) PaymentFailed { - return PaymentFailed{ - Event: BaseEvent("payment.failed", nil), - Payload: PaymentFailedPayload{ - OrderID: orderID, - PaymentID: paymentID, - UserID: userID, - Amount: amount, - Reason: reason, - FailedAt: time.Now().UTC(), - }, - } -} - -// ============================================================ -// INVENTORY EVENTS -// ============================================================ - -// InventoryUpdatedPayload is the payload for inventory.updated events -type InventoryUpdatedPayload struct { - ProductID string `json:"product_id"` - OrderID string `json:"order_id"` - ReservedQuantity int `json:"reserved_quantity"` - TotalQuantity int `json:"total_quantity"` - UpdatedAt time.Time `json:"updated_at"` -} - -// InventoryUpdated represents an inventory reservation event -type InventoryUpdated struct { - Event - Payload InventoryUpdatedPayload `json:"payload"` -} - -// NewInventoryUpdated creates a new inventory updated event -func NewInventoryUpdated(productID, orderID string, reserved, total int) InventoryUpdated { - return InventoryUpdated{ - Event: BaseEvent("inventory.updated", nil), - Payload: InventoryUpdatedPayload{ - ProductID: productID, - OrderID: orderID, - ReservedQuantity: reserved, - TotalQuantity: total, - UpdatedAt: time.Now().UTC(), - }, - } -} - -// InventoryFailedPayload is the payload for inventory.failed events -type InventoryFailedPayload struct { - ProductID string `json:"product_id"` - OrderID string `json:"order_id"` - Reason string `json:"reason"` - FailedAt time.Time `json:"failed_at"` -} - -// InventoryFailed represents a failed inventory reservation event -type InventoryFailed struct { - Event - Payload InventoryFailedPayload `json:"payload"` -} - -// NewInventoryFailed creates a new inventory failed event -func NewInventoryFailed(productID, orderID, reason string) InventoryFailed { - return InventoryFailed{ - Event: BaseEvent("inventory.failed", nil), - Payload: InventoryFailedPayload{ - ProductID: productID, - OrderID: orderID, - Reason: reason, - FailedAt: time.Now().UTC(), - }, - } -} - -// ============================================================ -// NOTIFICATION EVENTS -// ============================================================ - -// NotificationPayload is the payload for notification events -type NotificationPayload struct { - UserID string `json:"user_id"` - Email string `json:"email"` - Phone string `json:"phone,omitempty"` - Type string `json:"type"` - Subject string `json:"subject"` - Body string `json:"body"` - TemplateID string `json:"template_id,omitempty"` - Data map[string]string `json:"data,omitempty"` -} - -// Notification represents a notification event -type Notification struct { - Event - Payload NotificationPayload `json:"payload"` -} - -// NewNotification creates a new notification event -func NewNotification(userID, email, notificationType, subject, body string) Notification { - return Notification{ - Event: BaseEvent("notification.send", nil), - Payload: NotificationPayload{ - UserID: userID, - Email: email, - Type: notificationType, - Subject: subject, - Body: body, - }, - } -} - -// ============================================================ -// ENUM DEFINITIONS -// ============================================================ - -// Order status constants -const ( - OrderStatusPending = "PENDING" - OrderStatusConfirmed = "CONFIRMED" - OrderStatusProcessing = "PROCESSING" - OrderStatusShipped = "SHIPPED" - OrderStatusDelivered = "DELIVERED" - OrderStatusCancelled = "CANCELLED" - OrderStatusFailed = "FAILED" -) - -// Payment status constants -const ( - PaymentStatusPending = "PENDING" - PaymentStatusCompleted = "COMPLETED" - PaymentStatusFailed = "FAILED" - PaymentStatusRefunded = "REFUNDED" -) - -// Notification type constants -const ( - NotificationTypeEmail = "email" - NotificationTypeSMS = "sms" -) - -// Event type constants -const ( - EventUserRegistered = "user.registered" - EventOrderCreated = "order.created" - EventOrderCancelled = "order.cancelled" - EventPaymentProcessed = "payment.processed" - EventPaymentFailed = "payment.failed" - EventInventoryUpdated = "inventory.updated" - EventInventoryFailed = "inventory.failed" - EventNotificationSend = "notification.send" -) diff --git a/shared/events/user_events.go b/shared/events/user_events.go deleted file mode 100644 index 610f6b8..0000000 --- a/shared/events/user_events.go +++ /dev/null @@ -1,24 +0,0 @@ -package events - -const ( - UserCreatedTopic = "user.created" - UserUpdatedTopic = "user.updated" - - UserDeletedTopic = "user.deleted" -) - -type UserCreatedEvent struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` -} - -type UserUpdatedEvent struct { - ID string `json:"id"` - Email string `json:"email,omitempty"` - Name string `json:"name,omitempty"` -} - -type UserDeletedEvent struct { - ID string `json:"id"` -} diff --git a/shared/go.mod b/shared/go.mod deleted file mode 100644 index cf78584..0000000 --- a/shared/go.mod +++ /dev/null @@ -1,8 +0,0 @@ -module github.com/auron/shared - -go 1.21 - -require ( - github.com/redis/go-redis/v9 v9.4.0 - github.com/segmentio/kafka-go v0.4.47 -) diff --git a/shared/kafka/consumer.go b/shared/kafka/consumer.go deleted file mode 100644 index 3dd0e68..0000000 --- a/shared/kafka/consumer.go +++ /dev/null @@ -1,321 +0,0 @@ -// Package kafka provides reusable Kafka producer and consumer helpers for all Auron services. -package kafka - -import ( - "context" - "encoding/json" - "fmt" - "sync" - "time" - - "github.com/segmentio/kafka-go" -) - -// ConsumerConfig holds Kafka consumer configuration -type ConsumerConfig struct { - Brokers []string - Topic string - GroupID string - MinBytes int - MaxBytes int - MaxWait time.Duration - CommitInterval time.Duration - StartOffset int64 -} - -// MessageHandler is a function type for processing Kafka messages -type MessageHandler func(ctx context.Context, msg kafka.Message) error - -// Consumer wraps the Kafka reader with error handling and graceful shutdown -type Consumer struct { - reader *kafka.Reader - handler MessageHandler - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup -} - -// NewConsumer creates a new Kafka consumer -func NewConsumer(cfg *ConsumerConfig, handler MessageHandler) *Consumer { - reader := kafka.NewReader(kafka.ReaderConfig{ - Brokers: cfg.Brokers, - Topic: cfg.Topic, - GroupID: cfg.GroupID, - MinBytes: cfg.MinBytes, - MaxBytes: cfg.MaxBytes, - MaxWait: cfg.MaxWait, - CommitInterval: cfg.CommitInterval, - StartOffset: cfg.StartOffset, - // Error handler - // Logger: kafka.LoggerFunc(func(v ...interface{}) {}), - }) - - ctx, cancel := context.WithCancel(context.Background()) - - return &Consumer{ - reader: reader, - handler: handler, - ctx: ctx, - cancel: cancel, - } -} - -// NewConsumerWithDefaults creates a new Kafka consumer with default settings -func NewConsumerWithDefaults(brokers []string, topic string, groupID string, handler MessageHandler) *Consumer { - return NewConsumer(&ConsumerConfig{ - Brokers: brokers, - Topic: topic, - GroupID: groupID, - MinBytes: 1, - MaxBytes: 10e6, // 10MB - MaxWait: time.Second, - CommitInterval: time.Second, - StartOffset: kafka.LastOffset, - }, handler) -} - -// Start begins consuming messages -func (c *Consumer) Start() { - c.wg.Add(1) - go func() { - defer c.wg.Done() - for { - // Check if context is cancelled - select { - case <-c.ctx.Done(): - return - default: - } - - // Read message with context - msg, err := c.reader.ReadMessage(c.ctx) - if err != nil { - // Check if context was cancelled - if c.ctx.Err() != nil { - return - } - - // Log error but continue - fmt.Printf("Error reading Kafka message: %v\n", err) - continue - } - - // Process message - if err := c.handler(c.ctx, msg); err != nil { - fmt.Printf("Error handling Kafka message: %v\n", err) - // Could implement retry logic or DLQ here - } - } - }() -} - -// StartWithSync starts the consumer with synchronous message processing -func (c *Consumer) StartWithSync() { - c.wg.Add(1) - go func() { - defer c.wg.Done() - for { - select { - case <-c.ctx.Done(): - return - default: - } - - msg, err := c.reader.FetchMessage(c.ctx) - if err != nil { - if c.ctx.Err() != nil { - return - } - fmt.Printf("Error fetching Kafka message: %v\n", err) - continue - } - - if err := c.handler(c.ctx, msg); err != nil { - fmt.Printf("Error handling Kafka message: %v\n", err) - continue - } - - // Commit message after successful processing - if err := c.reader.CommitMessages(c.ctx, msg); err != nil { - fmt.Printf("Error committing Kafka message: %v\n", err) - } - } - }() -} - -// Stop stops the consumer gracefully -func (c *Consumer) Stop() error { - c.cancel() - c.wg.Wait() - return c.reader.Close() -} - -// Pause pauses the consumer -func (c *Consumer) Pause() { - c.reader.Pause() -} - -// Resume resumes the consumer -func (c *Consumer) Resume() { - c.reader.Resume() -} - -// SetOffset sets the offset to read from -func (c *Consumer) SetOffset(offset int64) error { - return c.reader.SetOffset(offset) -} - -// Lag returns the current lag of the consumer -func (c *Consumer) Lag() (int64, error) { - lag, err := c.reader.Lag() - if err != nil { - return 0, fmt.Errorf("failed to get consumer lag: %w", err) - } - return lag, nil -} - -// Stats returns consumer statistics -func (c *Consumer) Stats() kafka.ReaderStats { - return c.reader.Stats() -} - -// MessageConsumer creates a consumer function that handles specific message types -func MessageConsumer(handler func(ctx context.Context, key []byte, value []byte) error) MessageHandler { - return func(ctx context.Context, msg kafka.Message) error { - return handler(ctx, msg.Key, msg.Value) - } -} - -// JSONConsumer creates a consumer function that handles JSON messages -func JSONConsumer(handler func(ctx context.Context, key []byte, value interface{}) error) MessageHandler { - return func(ctx context.Context, msg kafka.Message) error { - var value interface{} - if err := json.Unmarshal(msg.Value, &value); err != nil { - return fmt.Errorf("failed to unmarshal JSON message: %w", err) - } - return handler(ctx, msg.Key, value) - } -} - -// TypedConsumer creates a consumer function that handles typed JSON messages -func TypedConsumer[T any](handler func(ctx context.Context, key []byte, value *T) error) MessageHandler { - return func(ctx context.Context, msg kafka.Message) error { - var value T - if err := json.Unmarshal(msg.Value, &value); err != nil { - return fmt.Errorf("failed to unmarshal typed message: %w", err) - } - return handler(ctx, msg.Key, &value) - } -} - -// MultiTopicConsumer consumes from multiple topics with different handlers -type MultiTopicConsumer struct { - consumers map[string]*Consumer - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup -} - -// NewMultiTopicConsumer creates a consumer that handles multiple topics -func NewMultiTopicConsumer(brokers []string, handlers map[string]MessageHandler) *MultiTopicConsumer { - consumers := make(map[string]*Consumer) - - for topic, handler := range handlers { - consumer := NewConsumerWithDefaults(brokers, topic, topic+"-consumer", handler) - consumers[topic] = consumer - } - - ctx, cancel := context.WithCancel(context.Background()) - - return &MultiTopicConsumer{ - consumers: consumers, - ctx: ctx, - cancel: cancel, - } -} - -// Start starts all topic consumers -func (m *MultiTopicConsumer) Start() { - for _, consumer := range m.consumers { - consumer.Start() - } -} - -// Stop stops all topic consumers -func (m *MultiTopicConsumer) Stop() error { - m.cancel() - m.wg.Wait() - - var errs []error - for _, consumer := range m.consumers { - if err := consumer.Stop(); err != nil { - errs = append(errs, err) - } - } - - if len(errs) > 0 { - return fmt.Errorf("errors stopping consumers: %v", errs) - } - return nil -} - -// CreateTopics creates Kafka topics if they don't exist -func CreateTopics(brokers []string, topics []string) error { - conn, err := kafka.DialLeader(context.Background(), "tcp", brokers[0], "__Aurontopics", 1) - if err != nil { - return fmt.Errorf("failed to dial Kafka leader: %w", err) - } - defer conn.Close() - - topicConfigs := make([]kafka.TopicConfig, len(topics)) - for i, topic := range topics { - topicConfigs[i] = kafka.TopicConfig{ - Topic: topic, - NumPartitions: 6, - ReplicationFactor: 1, - } - } - - err = conn.CreateTopics(topicConfigs...) - if err != nil { - return fmt.Errorf("failed to create topics: %w", err) - } - - return nil -} - -// EnsureTopics ensures all required topics exist -func EnsureTopics(brokers []string, requiredTopics []string) error { - conn, err := kafka.Dial("tcp", brokers[0]) - if err != nil { - return fmt.Errorf("failed to dial Kafka: %w", err) - } - defer conn.Close() - - // Get existing topics - existingTopics, err := conn.Topics() - if err != nil { - return fmt.Errorf("failed to get topics: %w", err) - } - - // Create missing topics - var topicsToCreate []string - for _, topic := range requiredTopics { - found := false - for _, existing := range existingTopics { - if topic == existing { - found = true - break - } - } - if !found { - topicsToCreate = append(topicsToCreate, topic) - } - } - - if len(topicsToCreate) > 0 { - return CreateTopics(brokers, topicsToCreate) - } - - return nil -} diff --git a/shared/kafka/producer.go b/shared/kafka/producer.go deleted file mode 100644 index 9dd2ee5..0000000 --- a/shared/kafka/producer.go +++ /dev/null @@ -1,196 +0,0 @@ -// Package kafka provides reusable Kafka producer and consumer helpers for all Auron services. -package kafka - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/segmentio/kafka-go" -) - -// ProducerConfig holds Kafka producer configuration -type ProducerConfig struct { - Brokers []string - Topic string -} - -// Producer wraps the Kafka writer with connection pooling and error handling -type Producer struct { - writer *kafka.Writer - topic string -} - -// NewProducer creates a new Kafka producer -func NewProducer(cfg *ProducerConfig) *Producer { - writer := &kafka.Writer{ - Addr: kafka.TCP(cfg.Brokers...), - Topic: cfg.Topic, - Balancer: &kafka.LeastBytes{}, - BatchSize: 1, - BatchTimeout: 10 * time.Millisecond, - RequiredAcks: kafka.RequireOne, - Compression: kafka.Snappy, - // Retry settings - MaxRetries: 3, - RetryBackoff: time.Millisecond * 100, - Async: false, - } - - return &Producer{ - writer: writer, - topic: cfg.Topic, - } -} - -// NewProducerWithConfig creates a new Kafka producer with custom configuration -func NewProducerWithConfig(brokers []string, topic string, balancer kafka.Balancer) *Producer { - writer := &kafka.Writer{ - Addr: kafka.TCP(brokers...), - Topic: topic, - Balancer: balancer, - BatchSize: 1, - BatchTimeout: 10 * time.Millisecond, - RequiredAcks: kafka.RequireOne, - Compression: kafka.Snappy, - MaxRetries: 3, - RetryBackoff: time.Millisecond * 100, - } - - return &Producer{ - writer: writer, - topic: topic, - } -} - -// Publish publishes a message to the Kafka topic -func (p *Producer) Publish(ctx context.Context, key []byte, value interface{}) error { - var msgValue []byte - var err error - - switch v := value.(type) { - case string: - msgValue = []byte(v) - case []byte: - msgValue = v - default: - msgValue, err = json.Marshal(v) - if err != nil { - return fmt.Errorf("failed to marshal message value: %w", err) - } - } - - msg := kafka.Message{ - Key: key, - Value: msgValue, - Time: time.Now(), - } - - if err := p.writer.WriteMessages(ctx, msg); err != nil { - return fmt.Errorf("failed to publish message: %w", err) - } - - return nil -} - -// PublishWithHeaders publishes a message with custom headers -func (p *Producer) PublishWithHeaders(ctx context.Context, key []byte, value interface{}, headers []kafka.Header) error { - var msgValue []byte - var err error - - switch v := value.(type) { - case string: - msgValue = []byte(v) - case []byte: - msgValue = v - default: - msgValue, err = json.Marshal(v) - if err != nil { - return fmt.Errorf("failed to marshal message value: %w", err) - } - } - - msg := kafka.Message{ - Key: key, - Value: msgValue, - Time: time.Now(), - Headers: headers, - } - - if err := p.writer.WriteMessages(ctx, msg); err != nil { - return fmt.Errorf("failed to publish message: %w", err) - } - - return nil -} - -// PublishJSON publishes a JSON message to the Kafka topic -func (p *Producer) PublishJSON(ctx context.Context, key []byte, value interface{}) error { - msgValue, err := json.Marshal(value) - if err != nil { - return fmt.Errorf("failed to marshal JSON message: %w", err) - } - - msg := kafka.Message{ - Key: key, - Value: msgValue, - Time: time.Now(), - } - - if err := p.writer.WriteMessages(ctx, msg); err != nil { - return fmt.Errorf("failed to publish JSON message: %w", err) - } - - return nil -} - -// PublishToTopic publishes a message to a specific topic -func (p *Producer) PublishToTopic(ctx context.Context, topic string, key []byte, value interface{}) error { - var msgValue []byte - var err error - - switch v := value.(type) { - case string: - msgValue = []byte(v) - case []byte: - msgValue = v - default: - msgValue, err = json.Marshal(v) - if err != nil { - return fmt.Errorf("failed to marshal message value: %w", err) - } - } - - // Create a temporary writer for the specific topic - writer := &kafka.Writer{ - Addr: p.writer.Addr, - Topic: topic, - Balancer: &kafka.LeastBytes{}, - RequiredAcks: kafka.RequireOne, - Compression: kafka.Snappy, - } - defer writer.Close() - - msg := kafka.Message{ - Key: key, - Value: msgValue, - Time: time.Now(), - } - - if err := writer.WriteMessages(ctx, msg); err != nil { - return fmt.Errorf("failed to publish message to topic %s: %w", topic, err) - } - - return nil -} - -// Close closes the producer -func (p *Producer) Close() error { - return p.writer.Close() -} - -// GetTopic returns the topic name -func (p *Producer) GetTopic() string { - return p.topic -} diff --git a/shared/middleware/recovery.go b/shared/middleware/recovery.go deleted file mode 100644 index 59adcf1..0000000 --- a/shared/middleware/recovery.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package middleware provides shared middleware for all Auron services. -package middleware - -import ( - "net/http" - "os" - "runtime/debug" - - "github.com/gin-gonic/gin" -) - -// Recovery returns a middleware that recovers from any panics -func Recovery() gin.HandlerFunc { - return func(c *gin.Context) { - defer func() { - if err := recover(); err != nil { - // Get stack trace - stack := debug.Stack() - - // Log the error - gin.DefaultWriter.Write([]byte("[PANIC RECOVERED]\n")) - gin.DefaultWriter.Write([]byte("Error: ")) - gin.DefaultWriter.Write([]byte(err.(error).Error())) - gin.DefaultWriter.Write([]byte("\n\nStack:\n")) - gin.DefaultWriter.Write(stack) - - // Get service name from environment or default - serviceName := os.Getenv("SERVICE_NAME") - if serviceName == "" { - serviceName = "auron-service" - } - - // Abort with 500 Internal Server Error - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ - "success": false, - "error": gin.H{ - "code": "INTERNAL_SERVER_ERROR", - "message": "An unexpected error occurred", - "service": serviceName, - }, - }) - } - }() - c.Next() - } -} diff --git a/shared/redis/client.go b/shared/redis/client.go deleted file mode 100644 index c009a5c..0000000 --- a/shared/redis/client.go +++ /dev/null @@ -1,306 +0,0 @@ -// Package redis provides a reusable Redis client wrapper for all Auron services. -package redis - -import ( - "context" - "fmt" - "time" - - "github.com/redis/go-redis/v9" -) - -// Config holds Redis connection configuration -type Config struct { - Addr string - Password string - DB int - PoolSize int -} - -// Client wraps the Redis client with connection pooling and health checks -type Client struct { - rdb *redis.Client -} - -// NewClient creates a new Redis client with the given configuration -func NewClient(cfg *Config) (*Client, error) { - rdb := redis.NewClient(&redis.Options{ - Addr: cfg.Addr, - Password: cfg.Password, - DB: cfg.DB, - PoolSize: cfg.PoolSize, - }) - - // Test the connection - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := rdb.Ping(ctx).Err(); err != nil { - return nil, fmt.Errorf("failed to connect to Redis: %w", err) - } - - return &Client{rdb: rdb}, nil -} - -// NewClientFromURL creates a new Redis client from a connection URL -// URL format: redis://[[username:]password@]host[:port][/database] -func NewClientFromURL(url string) (*Client, error) { - opt, err := redis.ParseURL(url) - if err != nil { - return nil, fmt.Errorf("failed to parse Redis URL: %w", err) - } - - rdb := redis.NewClient(opt) - - // Test the connection - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := rdb.Ping(ctx).Err(); err != nil { - return nil, fmt.Errorf("failed to connect to Redis: %w", err) - } - - return &Client{rdb: rdb}, nil -} - -// Get returns the underlying Redis client -func (c *Client) Get() *redis.Client { - return c.rdb -} - -// Ping checks the Redis connection -func (c *Client) Ping(ctx context.Context) error { - return c.rdb.Ping(ctx).Err() -} - -// Close closes the Redis connection -func (c *Client) Close() error { - return c.rdb.Close() -} - -// HealthCheck returns health status of Redis -func (c *Client) HealthCheck(ctx context.Context) error { - return c.Ping(ctx) -} - -// String operations - -// Set sets a key with expiration -func (c *Client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { - return c.rdb.Set(ctx, key, value, expiration).Err() -} - -// SetNX sets a key only if it doesn't exist -func (c *Client) SetNX(ctx context.Context, key string, value interface{}, expiration time.Duration) (bool, error) { - return c.rdb.SetNX(ctx, key, value, expiration).Result() -} - -// Get gets a key value -func (c *Client) Get(ctx context.Context, key string) (string, error) { - return c.rdb.Get(ctx, key).Result() -} - -// GetTTL gets the remaining TTL of a key -func (c *Client) GetTTL(ctx context.Context, key string) (time.Duration, error) { - return c.rdb.TTL(ctx, key).Result() -} - -// Expire sets expiration on a key -func (c *Client) Expire(ctx context.Context, key string, expiration time.Duration) (bool, error) { - return c.rdb.Expire(ctx, key, expiration).Result() -} - -// ExpireAt sets expiration on a key to a specific time -func (c *Client) ExpireAt(ctx context.Context, key string, tm time.Time) (bool, error) { - return c.rdb.ExpireAt(ctx, key, tm).Result() -} - -// Del deletes keys -func (c *Client) Del(ctx context.Context, keys ...string) (int64, error) { - return c.rdb.Del(ctx, keys...).Result() -} - -// Exists checks if keys exist -func (c *Client) Exists(ctx context.Context, keys ...string) (int64, error) { - return c.rdb.Exists(ctx, keys...).Result() -} - -// Incr increments a key -func (c *Client) Incr(ctx context.Context, key string) (int64, error) { - return c.rdb.Incr(ctx, key).Result() -} - -// IncrBy increments a key by amount -func (c *Client) IncrBy(ctx context.Context, key string, value int64) (int64, error) { - return c.rdb.IncrBy(ctx, key, value).Result() -} - -// Hash operations - -// HSet sets a hash field -func (c *Client) HSet(ctx context.Context, key string, values ...interface{}) (int64, error) { - return c.rdb.HSet(ctx, key, values...).Result() -} - -// HGet gets a hash field -func (c *Client) HGet(ctx context.Context, key, field string) (string, error) { - return c.rdb.HGet(ctx, key, field).Result() -} - -// HGetAll gets all hash fields -func (c *Client) HGetAll(ctx context.Context, key string) (map[string]string, error) { - return c.rdb.HGetAll(ctx, key).Result() -} - -// HDel deletes hash fields -func (c *Client) HDel(ctx context.Context, key string, fields ...string) (int64, error) { - return c.rdb.HDel(ctx, key, fields...).Result() -} - -// HExists checks if a hash field exists -func (c *Client) HExists(ctx context.Context, key, field string) (bool, error) { - return c.rdb.HExists(ctx, key, field).Result() -} - -// HLen gets the number of fields in a hash -func (c *Client) HLen(ctx context.Context, key string) (int64, error) { - return c.rdb.HLen(ctx, key).Result() -} - -// List operations - -// LPush pushes values to the left of a list -func (c *Client) LPush(ctx context.Context, key string, values ...interface{}) (int64, error) { - return c.rdb.LPush(ctx, key, values...).Result() -} - -// RPush pushes values to the right of a list -func (c *Client) RPush(ctx context.Context, key string, values ...interface{}) (int64, error) { - return c.rdb.RPush(ctx, key, values...).Result() -} - -// LRange gets a range of list elements -func (c *Client) LRange(ctx context.Context, key string, start, stop int64) ([]string, error) { - return c.rdb.LRange(ctx, key, start, stop).Result() -} - -// LPop removes and returns the leftmost element -func (c *Client) LPop(ctx context.Context, key string) (string, error) { - return c.rdb.LPop(ctx, key).Result() -} - -// Set operations - -// SAdd adds members to a set -func (c *Client) SAdd(ctx context.Context, key string, members ...interface{}) (int64, error) { - return c.rdb.SAdd(ctx, key, members...).Result() -} - -// SMembers gets all members of a set -func (c *Client) SMembers(ctx context.Context, key string) ([]string, error) { - return c.rdb.SMembers(ctx, key).Result() -} - -// SIsMember checks if a member exists in a set -func (c *Client) SIsMember(ctx context.Context, key string, member interface{}) (bool, error) { - return c.rdb.SIsMember(ctx, key, member).Result() -} - -// SRem removes members from a set -func (c *Client) SRem(ctx context.Context, key string, members ...interface{}) (int64, error) { - return c.rdb.SRem(ctx, key, members...).Result() -} - -// Sorted set operations - -// ZAdd adds members to a sorted set -func (c *Client) ZAdd(ctx context.Context, key string, members ...redis.Z) (int64, error) { - return c.rdb.ZAdd(ctx, key, members...).Result() -} - -// ZRangeByScore gets members by score range -func (c *Client) ZRangeByScore(ctx context.Context, key string, opt *redis.ZRangeBy) ([]string, error) { - return c.rdb.ZRangeByScore(ctx, key, opt).Result() -} - -// ZRem removes members from a sorted set -func (c *Client) ZRem(ctx context.Context, key string, members ...interface{}) (int64, error) { - return c.rdb.ZRem(ctx, key, members...).Result() -} - -// Pipeline operations - -// Pipeline creates a pipeline -func (c *Client) Pipeline() redis.Pipeliner { - return c.rdb.Pipeline() -} - -// TxPipeline creates a transaction pipeline -func (c *Client) TxPipeline() redis.Pipeliner { - return c.rdb.TxPipeline() -} - -// PubSub operations - -// Subscribe subscribes to channels -func (c *Client) Subscribe(ctx context.Context, channels ...string) *redis.PubSub { - return c.rdb.Subscribe(ctx, channels...) -} - -// Rate limiting helpers - -// RateLimit increments a counter and checks if it's within limits -// Returns true if within limit, false if exceeded -func (c *Client) RateLimit(ctx context.Context, key string, limit int, window time.Duration) (bool, error) { - count, err := c.Incr(ctx, key) - if err != nil { - return false, err - } - - // Set expiration on first request - if count == 1 { - if err := c.Expire(ctx, key, window); err != nil { - return false, err - } - } - - return count <= int64(limit), nil -} - -// Cache helpers - -// CacheSet caches a value with JSON serialization -func (c *Client) CacheSet(ctx context.Context, key string, value interface{}, ttl time.Duration) error { - return c.Set(ctx, key, value, ttl) -} - -// CacheGet gets a cached value -func (c *Client) CacheGet(ctx context.Context, key string, dest interface{}) error { - val, err := c.Get(ctx, key) - if err != nil { - return err - } - - // Note: For actual JSON deserialization, use json.Unmarshal - // This is just a helper that returns the string value - _ = dest // Placeholder for json.Unmarshal - return nil -} - -// InvalidatePattern deletes all keys matching a pattern -func (c *Client) InvalidatePattern(ctx context.Context, pattern string) (int64, error) { - iter := c.rdb.Scan(ctx, 0, pattern, 0).Iterator() - var keys []string - for iter.Next(ctx) { - keys = append(keys, iter.Val()) - } - if err := iter.Err(); err != nil { - return 0, err - } - - if len(keys) == 0 { - return 0, nil - } - - return c.Del(ctx, keys...) -}