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 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0315a78 --- /dev/null +++ b/README.md @@ -0,0 +1,333 @@ +# 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](./docs/API_DOCS.md). + +A ready-to-import Postman collection is at [Auron.postman_collection.json](./docs/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 +│ +├── 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 +│ ├── 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 +│ +├── 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. diff --git a/docs/API_CURL_TESTS.md b/docs/API_CURL_TESTS.md new file mode 100644 index 0000000..ac36375 --- /dev/null +++ b/docs/API_CURL_TESTS.md @@ -0,0 +1,744 @@ +# Auron API — curl Test Guide + +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 + +```bash +BASE=http://localhost:8080/api +``` + +Run the commands below **in order** — later steps depend on tokens and IDs from earlier steps. + +--- + +## 1. Gateway Health + +```bash +curl -s http://localhost:8080/api/health | jq +``` + +**Response:** +```json +{ "service": "auron-api", "status": "healthy" } +``` + +--- + +## 2. 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 +``` + +**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 — 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') + +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') + +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') +``` + +**Response:** +```json +{ + "access_token": "eyJhbGci...", + "refresh_token": "eyJhbGci..." +} +``` + +--- + +### Refresh token + +```bash +curl -s -X POST $BASE/auth/refresh \ + -H "Content-Type: application/json" \ + -d "{\"refresh_token\": \"$REFRESH_TOKEN\"}" | jq +``` + +**Response:** +```json +{ "access_token": "eyJhbGci...", "refresh_token": "eyJhbGci..." } +``` + +--- + +### 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 +``` + +**Response:** +```json +{ "success": true, "message": "logged out" } +``` + +--- + +## 3. User Profile + +### Get profile + +```bash +curl -s $BASE/users/me -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +**Response:** +```json +{ + "data": { "id": "", "email": "customer@auron.test", "name": "Test Customer", "role": "customer" }, + "success": true +} +``` + +--- + +### Update profile + +```bash +curl -s -X PUT $BASE/users/me \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "Updated Name"}' | jq +``` + +**Response:** +```json +{ + "data": { "id": "", "email": "customer@auron.test", "name": "Updated Name", "role": "customer" }, + "success": true +} +``` + +--- + +## 4. Addresses + +### Add address — save ID + +```bash +ADDRESS_ID=$(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 -r '.data.id') +``` + +**Response:** +```json +{ + "data": { "id": "", "label": "Home", "street": "123 Main St", "city": "Jakarta", "country": "Indonesia", "postal_code": "10110", "is_default": true }, + "success": true +} +``` + +--- + +### Get all addresses + +```bash +curl -s $BASE/users/me/addresses -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +**Response:** +```json +{ + "data": [{ "id": "", "label": "Home", "street": "123 Main St", "city": "Jakarta", ... }], + "success": true +} +``` + +--- + +### 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 +``` + +**Response:** +```json +{ + "data": { "id": "", "city": "Surabaya", ... }, + "success": true +} +``` + +--- + +### Delete address + +```bash +curl -s -X DELETE $BASE/users/me/addresses/$ADDRESS_ID \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +**Response:** +```json +{ "success": true, "message": "address deleted" } +``` + +--- + +## 5. Categories + +### Get all — public + +```bash +curl -s $BASE/categories | jq +``` + +**Response:** +```json +{ + "data": [{ "id": "", "name": "Electronics", "slug": "electronics", "created_at": "..." }], + "success": true +} +``` + +--- + +### 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') +``` + +**Response:** +```json +{ + "data": { "id": "", "name": "Electronics", "slug": "electronics", "created_at": "..." }, + "success": true +} +``` + +--- + +## 6. Products + +### Create — admin only, save ID + +```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') +``` + +**Response:** +```json +{ + "data": { "id": "", "name": "iPhone 15 Pro", "price": 15999000, "is_active": true, ... }, + "success": true +} +``` + +--- + +### List — public, with filters + +```bash +# All products +curl -s "$BASE/products" | jq '.data[0] | {id,name,price}' + +# Full-text search +curl -s "$BASE/products?q=iphone" | jq '{total: .meta.total, first: .data[0].name}' + +# Filter + sort + paginate +curl -s "$BASE/products?category_id=$CATEGORY_ID&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 by ID — public + +```bash +curl -s "$BASE/products/$PRODUCT_ID" | jq '.data | {id,name,price}' +``` + +**Response:** +```json +{ + "data": { "id": "", "name": "iPhone 15 Pro", "price": 15999000, ... }, + "success": true +} +``` + +--- + +### 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\":\"512GB\",\"price\":18999000,\"is_active\":true}" | jq '.data | {name,price}' +``` + +**Response:** +```json +{ "data": { "name": "iPhone 15 Pro Max", "price": 18999000, ... }, "success": true } +``` + +--- + +### Delete — admin only + +```bash +curl -s -X DELETE "$BASE/products/$PRODUCT_ID" -H "Authorization: Bearer $ADMIN_TOKEN" | jq +``` + +**Response:** +```json +{ "success": true, "message": "product deleted" } +``` + +--- + +## 7. Inventory + +### Get stock — **public, no auth required** + +```bash +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 +} +``` + +> Returns 404 if inventory has never been set for this product. + +--- + +### 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 +``` + +**Response:** +```json +{ + "data": { "product_id": "", "total_quantity": 50, "reserved_quantity": 0, "available_quantity": 50, "updated_at": "..." }, + "success": true +} +``` + +--- + +## 8. 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 '.data | {total, items_count: (.items|length)}' +``` + +**Response:** +```json +{ "data": { "total": 31998000, "items": [...] }, "success": true } +``` + +--- + +### Get cart — save item ID + +```bash +CART_ITEM_ID=$(curl -s "$BASE/cart" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq -r '.data.items[0].id') +``` + +**Response:** +```json +{ + "data": { "id": "", "user_id": "", "items": [{ "id": "", "product_id": "", "quantity": 2, "price": 15999000, "subtotal": 31998000, ... }], "total": 31998000 }, + "success": true +} +``` + +--- + +### 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 '.data | {total, new_qty: .items[0].quantity}' +``` + +**Response:** +```json +{ "data": { "total": 15999000, "items": [{ "quantity": 1, ... }] }, "success": true } +``` + +--- + +### Remove item + +```bash +curl -s -X DELETE "$BASE/cart/items/$CART_ITEM_ID" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +**Response:** +```json +{ "success": true, "message": "item removed from cart" } +``` + +--- + +## 9. Orders + +### 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') +``` + +**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 +} +``` + +--- + +### Verify cart was cleared + +```bash +curl -s "$BASE/cart" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data | {total, items_count: (.items|length)}' +``` + +**Response:** `{ "total": 0, "items_count": 0 }` ✅ + +--- + +### Get all orders + +```bash +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 } +``` + +--- + +### Get order by ID + +```bash +curl -s "$BASE/orders/$ORDER_ID" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data | {id,status,total_amount}' +``` + +**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 '.data | {id,status}' +``` + +**Response:** +```json +{ "data": { "id": "", "status": "cancelled" }, "success": true } +``` + +--- + +## 10. Payments + +> 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` + +```bash +# Place a fresh order first, then wait ~1-3s for Kafka +sleep 3 + +curl -s "$BASE/payments/order/$ORDER_ID" \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data | {id,status,amount,currency,stripe_payment_intent_id,client_secret}' +``` + +**Response:** +```json +{ + "data": { + "id": "", + "order_id": "", + "user_id": "", + "status": "pending", + "amount": 15999000, + "currency": "usd", + "stripe_payment_intent_id": "pi_3Tc0zrRr2KxYotum0gxE3rT4", + "client_secret": "pi_3Tc0zrRr2KxYotum0gxE3rT4_secret_...", + "created_at": "..." + }, + "success": true +} +``` + +> 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 — no `client_secret` + +```bash +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 +} +``` + +> `client_secret` is omitted from this response. Use `/payments/order/:order_id` for checkout. + +--- + +### Stripe webhook — full end-to-end test + +```bash +# 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 + +# 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') + +sleep 3 # wait for Kafka + +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') + +# 3. Confirm the PaymentIntent using the Stripe CLI with a test card +stripe payment_intents confirm $PI --payment-method=pm_card_visa + +# 4. Verify status updated to completed +sleep 3 +curl -s "$BASE/payments/$PAYMENT_ID" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq '.data | {id, status}' +``` + +**Response after confirmation:** +```json +{ "data": { "id": "", "status": "completed" }, "success": true } +``` + +**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 + +--- + +## 11. Error Cases + +### No auth token → 401 + +```bash +curl -s $BASE/users/me | jq +``` + +```json +{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Authorization header is required" } } +``` + +--- + +### Customer accessing admin endpoint → 403 + +```bash +curl -s -X POST $BASE/categories \ + -H "Authorization: Bearer $CUSTOMER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"Hack","slug":"hack"}' | jq +``` + +```json +{ "success": false, "error": { "code": "FORBIDDEN", "message": "Insufficient permissions" } } +``` + +--- + +### Order with empty cart → 400 + +```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 +``` + +```json +{ "success": false, "error": "cart is empty" } +``` + +--- + +### Payment not yet processed by Kafka → 404 + +```bash +# Immediately after POST /orders (before Kafka processes) +curl -s "$BASE/payments/order/$ORDER_ID" -H "Authorization: Bearer $CUSTOMER_TOKEN" | jq +``` + +```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) | diff --git a/docs/API_DOCS.md b/docs/API_DOCS.md new file mode 100644 index 0000000..ba82cde --- /dev/null +++ b/docs/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/docs/Auron.postman_collection.json b/docs/Auron.postman_collection.json new file mode 100644 index 0000000..46fe848 --- /dev/null +++ b/docs/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." + } + } + ] + } + ] +} 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/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_ 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/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/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/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/domain/payment.go b/services/payment-service/internal/domain/payment.go index 5151f85..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)"` @@ -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..b58343c 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) @@ -140,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 } 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) } 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/services/product-service/internal/domain/product.go b/services/product-service/internal/domain/product.go index 9dc7295..38c7cae 100644 --- a/services/product-service/internal/domain/product.go +++ b/services/product-service/internal/domain/product.go @@ -47,9 +47,9 @@ 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:"type:tsvector;index:idx_products_search,type:GIN"` + 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()"` 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()}) } } 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...) -}