diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..76eb06b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache: true + + - name: Verify gofmt + run: | + fmt_out="$(gofmt -l .)" + if [ -n "$fmt_out" ]; then + echo "The following files are not gofmt'd:" + echo "$fmt_out" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Build + run: go build ./... + + - name: Test + run: go test ./... diff --git a/.gitignore b/.gitignore index 8d78a21..72d73fc 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,7 @@ build/ .cursorrules # Secrets -secrets.yaml \ No newline at end of file +secrets.yaml + +# Accidentally committed build artifact (removed from git history tracking) +/api-gateway diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..1e7e2a8 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,101 @@ +# AskShop Architecture + +## Service Map + +``` + ┌─────────────┐ + │ Client │ + └──────┬──────┘ + │ HTTP /api/v1/* + ▼ + ┌─────────────┐ + │ api-gateway │ :8081 + └──────┬──────┘ + ┌───────────┬─────────┼─────────┬───────────┐ + │ REST │ REST │ REST │ REST │ REST + ▼ ▼ ▼ ▼ ▼ + ┌──────────┐┌──────────┐┌────────┐┌─────────┐┌──────────┐ + │ user- ││ product- ││ cart- ││ order- ││ ai- │ + │ service ││ service ││service ││ service ││ service │ + │ :8084 ││ :8082 ││ :8083 ││ :8085 ││ :8086 │ + └────┬─────┘└────┬─────┘└───┬────┘└────┬────┘└────┬─────┘ + │ │ gRPC:9090│ │ REST │ REST + │ └──────────┤ │ │ + │ │◄──────────┘ │ + │ │◄─────────────────────┘ + ▼ ▼ + ┌─────────────────────────────────┐ ┌──────────┐ + │ Postgres │ │ RabbitMQ │ + │ (per-service tables, one DB) │ │ askshop. │ + └─────────────────────────────────┘ │ events │ + └────┬─────┘ + │ + order-service publishes + order.event.placed, and + runs a demo consumer that + logs what it receives. +``` + +- **api-gateway** is the only public entry point. It proxies to each service over plain REST + (`services/api-gateway/rest/client.go`), unwrapping/rewrapping the shared response envelope. + It does not own any business logic or state. +- **user-service** owns identity: Supabase-backed register/login, with a local JWT fallback. + Postgres-backed; no fallback if the DB is unreachable. +- **product-service** owns the catalog (products, images, categories, price, stock). Exposes + both REST (for the gateway and other services' HTTP clients) and gRPC (`shared/proto/product.proto`, + used by cart-service for live pricing lookups). Falls back to an in-memory repository if + Postgres is unreachable — the only service that does. +- **cart-service** owns carts and cart items. Calls product-service's gRPC endpoint on every + `AddItem`/`ValidateCartForCheckout` call so prices and stock are never stale or hardcoded. + Requires Postgres to start. +- **order-service** owns orders. On `PlaceOrder` it calls cart-service over REST to validate + and read the cart, snapshots it into an `Order`, tells cart-service to mark the cart converted, + and publishes `order.event.placed`. Requires Postgres to start. +- **ai-service** is stateless — no database of its own. It calls product-service (search) and + cart-service (abandoned carts) over REST, and Gemini over the network. Every route degrades to + 503 if `GEMINI_API_KEY`/`GOOGLE_API_KEY` is unset. +- **auth-service** is currently an unimplemented placeholder (see Roadmap in README.md). + +## Request Flow: Checkout + +1. Client calls `POST /api/v1/cart/items` (via gateway) → cart-service calls product-service's + gRPC `GetProduct` to get authoritative name/SKU/price/stock, builds the cart item, persists it. +2. Client calls `POST /api/v1/orders` (via gateway) → order-service: + a. Calls cart-service's `POST /api/v1/cart/checkout/validate` (which itself re-checks every + item against product-service for price changes and stock). + b. If valid, calls cart-service's `GET /api/v1/cart` to read the current contents. + c. Persists an `Order` + `OrderItem`s as a price/quantity snapshot. + d. Calls cart-service's `POST /api/v1/cart/checkout/complete` to mark the cart converted. + e. Publishes `order.event.placed` to RabbitMQ (no-ops with a log line if RabbitMQ isn't + configured — see `shared/events`). + +## Event Flow (RabbitMQ) + +`shared/events` wraps a single topic exchange (`askshop.events`), with routing keys declared in +`shared/contracts/amqp.go` (`order.event.placed`, `cart.cmd.*`, `ai.cmd.explain_product`, etc.). +Only `order.event.placed` is wired end-to-end today: order-service publishes it, and runs its own +`order-service.notify-stub` consumer that logs what it receives — a stand-in for a future +dedicated notification service. The other routing keys are declared but not yet published or +consumed by anything. + +## Why Prices Are Integers + +`Product.PriceCents` and `OrderItem.UnitPriceCents` are `int64` minor-currency-unit fields, not +floats, specifically to avoid rounding drift across service boundaries (product → cart → order, +each doing its own arithmetic). `CartItem.UnitPrice` is still a `float64` (pre-existing scaffolding +this revamp built on) — conversion happens at the cart-service boundary +(`services/cart-service/internal/service/cart_service.go`), so a full float→cents migration of +the cart model is a reasonable future cleanup, not a blocker. + +## Known Architectural Gaps + +- **No JWT verification at the gateway.** Cart/order identity is currently forwarded via an + `X-User-ID` header the gateway trusts as-is. Wiring `shared/auth` (or a real auth-service) + into the gateway's request path is the next auth-hardening step. +- **Cart has a unique index on `user_id`.** Once a cart is marked `converted`, the same user + can't get a fresh `active` cart without a schema change (a status-scoped unique index, or a + new cart row per checkout) — not yet hit in practice since carts aren't cleared post-checkout, + but worth knowing before building repeat-checkout flows. +- **No distributed tracing.** Request IDs propagate service-to-service (see + `shared/logger`/`shared/response`), but there's no OpenTelemetry/Jaeger-style trace across the + gateway → cart → product hop chain. diff --git a/README.md b/README.md index 2c45b44..cf86733 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,16 @@ AskShop is a Go-based, service-oriented commerce backend that experiments with Supabase authentication, gRPC between services, and an API Gateway front door. The repository is organised as a modular monorepo so individual verticals (user, product, cart, order, AI) can evolve independently while sharing contracts, utilities, and infrastructure scripts. ## Architecture at a Glance -- API Gateway exposes REST endpoints on port 8081 and forwards requests to downstream services with shared tracing, response, and validation helpers. -- User Service provides registration and login workflows that use Supabase Auth in production and fall back to local JWT validation when Supabase is unavailable. -- Product Service exposes a gRPC API (port 9090) with skeletal HTTP handlers. It currently runs with an in-memory repository while the PostgreSQL implementation is being finalised. -- Cart, Order, Auth, and AI services are scaffolded entry points ready for future business logic. The `tools/create_service.go` helper can spin up additional services with the same clean architecture layout. -- Shared packages (`shared/...`) offer common contracts (HTTP, gRPC, AMQP event keys), environment loading, database setup (GORM + PostgreSQL), structured logging, retry helpers, and Supabase client utilities. -- Infrastructure is scripted for either Kubernetes (via Tilt + Docker builds) or manual local execution. Secrets are injected through `.env` files or Kubernetes secrets. +See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full service map and request/event flow diagrams. Short version: +- API Gateway (port 8081) is the only public entry point; it proxies to every backend service over REST and owns no business logic of its own. +- User Service handles registration/login, using Supabase Auth in production with a local JWT fallback. +- Product Service (REST :8082, gRPC :9090) owns the catalog — Postgres-backed with an in-memory fallback if the DB is unreachable. +- Cart Service (:8083) owns carts, pricing every item live against Product Service over gRPC (never hardcoded). +- Order Service (:8085) validates and converts carts into orders, publishing `order.event.placed` over RabbitMQ. +- AI Service (:8086) is a Gemini-backed shopping assistant, product-explanation, and cart-nudge service, grounded in the real catalog via function calling — inert (503) without a `GEMINI_API_KEY`. +- Auth Service is still an unimplemented placeholder; the `tools/create_service.go` helper can scaffold additional services with the same clean-architecture layout. +- Shared packages (`shared/...`) provide common contracts (HTTP/gRPC/AMQP), environment loading, database setup (GORM + PostgreSQL), a RabbitMQ pub/sub wrapper, structured logging, a `/healthz` helper, retry helpers, and Supabase client utilities. +- Infrastructure supports three local-dev paths: Tilt + Kubernetes, `docker compose`, or running each Go binary manually. Secrets are injected through `.env` files or Kubernetes secrets. ## Repository Layout ``` @@ -66,12 +70,22 @@ All services load configuration from environment variables (with `.env` support ENV=development HTTP_ADDR=:8084 USER_SERVICE_URL=http://localhost:8084 +PRODUCT_SERVICE_URL=http://localhost:8082 +CART_SERVICE_URL=http://localhost:8083 +ORDER_SERVICE_URL=http://localhost:8085 PRODUCT_SERVICE_GRPC_ADDR=product-service:9090 CART_SERVICE_GRPC_ADDR=cart-service:9091 ORDER_SERVICE_GRPC_ADDR=order-service:9092 PAYMENT_SERVICE_GRPC_ADDR=payment-service:9093 AUTH_SERVICE_GRPC_ADDR=auth-service:9094 +# RabbitMQ (optional — order-service degrades to log-only if unset/unreachable) +RABBITMQ_URL=amqp://guest:guest@localhost:5672/ + +# AI service (optional — endpoints return 503 until this is set) +GEMINI_API_KEY= +AI_MODEL=gemini-3.6-flash + # Database (PostgreSQL) DATABASE_URL=postgresql://postgres:postgres@localhost:5432/askshop?sslmode=disable DB_HOST=localhost @@ -113,13 +127,32 @@ kubectl rollout restart deployment user-service ### Option 2: Run individual services locally The services are standard Go applications. In separate terminals: ```bash -# Start the user service (HTTP :8084) +# Start Postgres and RabbitMQ (or point DB_HOST/RABBITMQ_URL at existing instances) + +# User service (HTTP :8084) go run ./services/user-service/cmd/main.go -# Start the API gateway (HTTP :8081) +# Product service (HTTP :8082, gRPC :9090) +go run ./services/product-service/cmd + +# Cart service (HTTP :8083) — requires product-service's gRPC endpoint +go run ./services/cart-service/cmd + +# Order service (HTTP :8085) — requires cart-service's HTTP endpoint +go run ./services/order-service/cmd + +# AI service (HTTP :8086) — optional; set GEMINI_API_KEY to enable it, +# otherwise its endpoints return 503 +go run ./services/ai-service/cmd + +# API gateway (HTTP :8081) — fronts all of the above go run ./services/api-gateway ``` -Then exercise endpoints: +Seed the product catalog for local testing: +```bash +go run ./services/product-service/cmd/seed +``` +Then exercise the full commerce flow through the gateway: ```bash # Register a user (Supabase-powered) curl -X POST http://localhost:8081/api/v1/auth/register \ @@ -130,8 +163,42 @@ curl -X POST http://localhost:8081/api/v1/auth/register \ curl -X POST http://localhost:8081/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"demo@example.com","password":"Demo123!"}' + +# Browse products +curl http://localhost:8081/api/v1/products + +# Add an item to cart (X-User-ID stands in for JWT-derived identity until +# the gateway forwards it automatically) +curl -X POST http://localhost:8081/api/v1/cart/items \ + -H 'X-User-ID: demo-user' -H 'Content-Type: application/json' \ + -d '{"productId":"","quantity":1}' + +# Place an order from the cart +curl -X POST http://localhost:8081/api/v1/orders -H 'X-User-ID: demo-user' + +# Ask the shopping assistant (requires GEMINI_API_KEY on ai-service) +curl -X POST http://localhost:8081/api/v1/ai/chat \ + -H 'Content-Type: application/json' \ + -d '{"message":"show me wireless headphones under $50"}' +``` +auth-service still ships with a stub `main` function. + +### Option 3: docker compose (fastest to start, no Tilt/K8s required) +For local iteration without a Kubernetes cluster: +```bash +docker compose up -d postgres rabbitmq # infra first +docker compose up -d # start every service +docker compose run --rm product-service go run ./services/product-service/cmd/seed ``` -Product, cart, order, and AI services currently ship with stub `main` functions and are best driven through Tilt while their implementations are completed. +Each service container runs `go run` against the mounted source tree — no image build step, +edits take effect on `docker compose restart `. Set `GEMINI_API_KEY`, `SUPABASE_URL`, +`SUPABASE_KEY`, and (only if you want to override the `postgres` local-dev default) `POSTGRES_USER` +in a `.env` file at the repo root (docker compose reads it automatically) to enable AI features +and Supabase auth. The compose Postgres container uses trust auth (no password needed or checked) +since it's local-only and never exposed beyond the compose network. Ports match the manual/Tilt +setup: gateway on `:8081`, +product on `:8082`/`:9090` (gRPC), cart on `:8083`, user on `:8084`, order on `:8085`, AI on +`:8086`, Postgres on `:5432`, RabbitMQ on `:5672` (management UI on `:15672`). ## API Surface The gateway exposes REST resources under `/api/v1` using the contracts defined in `shared/contracts/routes.go`. @@ -139,11 +206,25 @@ The gateway exposes REST resources under `/api/v1` using the contracts defined i - `POST /api/v1/auth/login` – email/password login (returns Supabase tokens). - `GET /api/v1/auth/service-diagnostics` – development-only pass-through to user service diagnostics. - `GET /api/v1/users/:id` and `GET /api/v1/users/by-email?email=` – fetch user profiles (served by user-service). +- `GET /api/v1/products`, `GET /api/v1/products/:id`, `GET /api/v1/products?q=` – browse and search the catalog (served by product-service). +- `GET /api/v1/cart`, `POST /api/v1/cart/items`, `PUT|DELETE /api/v1/cart/items/:itemId` – manage the caller's cart (served by cart-service, priced live against product-service). +- `POST /api/v1/orders`, `GET /api/v1/orders`, `GET /api/v1/orders/:id` – place and read orders (served by order-service; validates and converts the cart, publishes `order.event.placed`). +- `POST /api/v1/ai/chat` – conversational shopping assistant (served by ai-service; uses a `search_products` tool against product-service so answers are grounded in the real catalog, never invented). +- `POST /api/v1/ai/products/:id/explain` – short AI-generated explanation of a single product. Use `AskShop.postman_collection.json` with the accompanying environment file to play through end-to-end flows. ## Database & Migrations -`shared/db` encapsulates PostgreSQL connectivity via GORM. Each service provides auto-migrations when the database is reachable (for example, `UserModel` in the user service and `Product`, `ProductImage`, `Category` in the product service). If a connection cannot be established (e.g., local development without PostgreSQL), services gracefully fall back to in-memory repositories where available. +`shared/db` encapsulates PostgreSQL connectivity via GORM. Each service provides auto-migrations when the database is reachable: `UserModel` (user-service), `Product`/`ProductImage`/`Category` (product-service), `Cart`/`CartItem`/`SavedItem` (cart-service), `Order`/`OrderItem` (order-service). product-service falls back to an in-memory repository if Postgres is unreachable; cart-service and order-service require Postgres to start. + +## AI Features (ai-service) +ai-service (`services/ai-service`) uses the official [Google Gen AI Go SDK](https://github.com/googleapis/go-genai) (Gemini, model configurable via `AI_MODEL`, default `gemini-3.6-flash`) and stays disabled (503 on every AI route, logged once at startup) until `GEMINI_API_KEY` (or `GOOGLE_API_KEY`) is set — nothing else fails to start because of it. +- **Conversational shopping assistant** (`POST /api/v1/ai/chat`) — a manual function-calling loop (see `internal/service/chat_service.go`) where Gemini calls a `search_products` function backed by product-service's real catalog before answering, so it can't invent products, prices, or stock. Accepts optional `history` for multi-turn conversations. +- **Product explanations** (`POST /api/v1/ai/products/:id/explain`) — a single grounded call using only the fetched product's real fields; publishes `ai.cmd.explain_product` over RabbitMQ. +- **Cart-abandonment nudges** (`POST /api/v1/ai/cart-nudges`, internal/batch — not proxied through the gateway) — reads cart-service's `GET /api/v1/cart/admin/abandoned` and generates a short re-engagement message per abandoned cart. Intended to be triggered on a schedule. + +## Events (RabbitMQ) +`shared/events` is a thin publisher/consumer over a single `askshop.events` topic exchange, keyed by the routing keys declared in `shared/contracts/amqp.go`. order-service publishes `order.event.placed` after checkout and runs a demonstration consumer (`order-service.notify-stub`) that logs what it receives — a stand-in for a future dedicated notification service. If `RABBITMQ_URL` is unset or the broker is unreachable, publishing/consuming no-ops with a log line rather than failing service startup. ## Protobuf & gRPC The product service includes a gRPC definition under `shared/proto/product.proto` with generated bindings in the same folder. @@ -154,6 +235,10 @@ The product service includes a gRPC definition under `shared/proto/product.proto - `shared/logger` provides structured logging with context propagation and a Gin middleware that attaches `request_id` metadata. API gateway requests automatically include request IDs in downstream calls. - `shared/response` normalises HTTP response envelopes (`success`, `error`, `trace_id`) so clients receive consistent payloads. - `shared/retry` offers exponential backoff helpers for external integrations. +- `shared/health` registers a consistent `GET /healthz` on every service (`{"service": "", "status": "ok"}`), for container orchestrators and manual checks. It doesn't currently check DB/broker connectivity — a liveness check, not a full readiness check. + +## Testing +- `go test ./...` runs unit tests for pure domain logic: cart-service's `CartItemBuilder`/`CartManager` (validation, totals, abandonment detection), product-service's slugify/`BeforeCreate` hooks and pagination clamping, and small `shared/` helpers (`contracts.JoinPaths`, `util.GetRandomAvatar`). Repository and HTTP-handler layers are still only covered by manual/Postman testing — no DB-backed or integration tests yet. ## Scripts & Tooling - `scripts/diagnose-supabase.sh` – interactive DNS and HTTP troubleshooting for Supabase projects. @@ -174,11 +259,43 @@ The product service includes a gRPC definition under `shared/proto/product.proto 4. When adding a service, prefer `tools/create_service.go -name ` to inherit the standard layout and README template. ## Roadmap & Known Gaps -- Product service needs a persistent repository implementation and HTTP/REST surface. -- Cart, order, auth, and AI services are placeholders awaiting domain logic. -- RabbitMQ integration (declared in `shared/contracts/amqp.go`) is not wired yet. +- The core commerce flow now works end to end: browse products → add to cart (priced live from + product-service) → checkout → order created → cart converted → `order.event.placed` published + and consumed over RabbitMQ. Verified locally against real Postgres and RabbitMQ instances. +- Product prices are modeled as `priceCents`/`stockQuantity` (integer minor units), not floating + currency amounts, to avoid rounding drift; the gRPC contract (`shared/proto/product.proto`) and + domain models were extended accordingly. +- The gateway forwards identity via an `X-User-ID` header for now — there's no JWT verification + in the request path yet. Wiring `shared/auth` (or auth-service, still a placeholder) through + the gateway is the next auth-hardening step. +- ai-service now has a real conversational shopping assistant, product explanations, and + cart-abandonment nudges (see AI Features above), grounded in the catalog via tool use — it's + just inert without `GEMINI_API_KEY`. auth-service is still an empty placeholder. + Semantic/vector search is a reasonable next step beyond the current keyword search. +- cart-service and order-service require a reachable Postgres to start (no in-memory fallback); + product-service still degrades to an in-memory repository if Postgres is unreachable. +- The AI cart-nudge endpoint has no scheduler wired up yet — it's designed to be triggered by + cron/Tilt/an external job runner, not called automatically. +- **Fixed: Tilt/Kubernetes port config drift.** The container ports in + `infra/development/k8s/*-deployment.yaml`, the `port_forwards` in `Tiltfile`, and each + service's actual `HTTP_ADDR` default in code used to disagree for product-service, cart-service, + ai-service, and order-service — and the API gateway's `HTTP_ADDR` was wired from a configmap key + (`GATEWAY_HTTP_ADDR`) the code never read, so changing it silently did nothing. All of these are + now aligned to each service's real default (product-service also gained a `grpc` port/service + entry for `:9090`, previously not exposed to other pods at all, which would have broken + cart-service's gRPC pricing lookups in-cluster). The seeder job's `golang:1.21` base image was + also bumped to `1.24-bookworm` to match `go.mod`'s `go 1.24.2` requirement. This was a static + consistency fix verified by YAML-parsing every edited manifest — not by deploying to a live + cluster, since none was available in this environment. Please sanity-check a real `tilt up` + before depending on it for anything important. - Tilt pipeline currently builds Linux/amd64 binaries; adjust if your target architecture differs. -- Additional automated tests (integration + contract tests) are being curated. +- `go test ./...` now covers cart-service and product-service's pure domain logic (see Testing + above) plus a couple of `shared/` helpers — repository/handler layers and cross-service flows + are still untested beyond manual/Postman checks. A GitHub Actions CI workflow + (`.github/workflows/ci.yml`) runs `gofmt`, `go vet`, `go build`, and `go test` on every push/PR + to `main`. +- Every service now exposes `GET /healthz` (see Logging/Observability above), but it's a liveness + check only — it doesn't verify DB/broker connectivity, so it can't yet back a real readiness probe. ## Support & Questions Open an issue or leave notes in the relevant service README. When investigating Supabase connectivity, start with `scripts/diagnose-supabase.sh` and ensure your `.env` matches the latest Supabase dashboard credentials. diff --git a/Tiltfile b/Tiltfile index 0954b0c..aabbbff 100644 --- a/Tiltfile +++ b/Tiltfile @@ -63,7 +63,7 @@ docker_build_with_restart( ], ) k8s_yaml('./infra/development/k8s/product-service-deployment.yaml') -k8s_resource('product-service', port_forwards='8082:8080', +k8s_resource('product-service', port_forwards=[8082, 9090], resource_deps=['product-service-compile'], labels="services") ### End of Product Service ### @@ -90,7 +90,7 @@ docker_build_with_restart( ], ) k8s_yaml('./infra/development/k8s/cart-service-deployment.yaml') -k8s_resource('cart-service', port_forwards='8086:8080', +k8s_resource('cart-service', port_forwards=8083, resource_deps=['cart-service-compile'], labels="services") ### End of Cart Service ### ### AI Service ### @@ -116,7 +116,7 @@ docker_build_with_restart( ], ) k8s_yaml('./infra/development/k8s/ai-service-deployment.yaml') -k8s_resource('ai-service', port_forwards='8087:8080', +k8s_resource('ai-service', port_forwards=8086, resource_deps=['ai-service-compile'], labels="services") ### End of AI Service ### ### User Service ### @@ -142,7 +142,7 @@ docker_build_with_restart( ], ) k8s_yaml('./infra/development/k8s/user-service-deployment.yaml') -k8s_resource('user-service', port_forwards='8085:8080', +k8s_resource('user-service', port_forwards=8084, resource_deps=['user-service-compile'], labels="services") ### End of User Service ### ### Order Service ### @@ -168,7 +168,7 @@ docker_build_with_restart( ], ) k8s_yaml('./infra/development/k8s/order-service-deployment.yaml') -k8s_resource('order-service', port_forwards='8084:8080', +k8s_resource('order-service', port_forwards=8085, resource_deps=['order-service-compile'], labels="services") ### End of Order Service ### diff --git a/api-gateway b/api-gateway deleted file mode 100755 index 8ad83a5..0000000 Binary files a/api-gateway and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cae3cc6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,158 @@ +name: askshop + +# Lightweight local-dev alternative to the Tilt + Kubernetes workflow: no +# cluster required, just `docker compose up`. Services run via `go run` +# against the mounted source tree (not production images — see +# infra/production for those) so code edits pick up on container restart. +# +# Usage: +# docker compose up -d postgres rabbitmq # infra first +# docker compose up -d # everything, or add specific services +# docker compose run --rm product-service go run ./services/product-service/cmd/seed + +x-go-service: &go-service + image: golang:1.24-bookworm + working_dir: /app + volumes: + - .:/app + - go-mod-cache:/go/pkg/mod + environment: &go-env + ENV: development + DB_HOST: postgres + DB_PORT: "5432" + DB_USER: ${POSTGRES_USER:-postgres} + DB_NAME: askshop + DB_SSLMODE: disable + RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/ + PRODUCT_SERVICE_URL: http://product-service:8082 + PRODUCT_SERVICE_GRPC_ADDR: product-service:9090 + CART_SERVICE_URL: http://cart-service:8083 + ORDER_SERVICE_URL: http://order-service:8085 + USER_SERVICE_URL: http://user-service:8084 + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + # Local-dev only, never exposed beyond this compose network: trust auth + # means no password is needed (or checked) at all, so none is set here. + # Override by setting POSTGRES_HOST_AUTH_METHOD/POSTGRES_PASSWORD in a + # .env file if you want real auth for some reason. + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: askshop + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 10 + + rabbitmq: + image: rabbitmq:3-management-alpine + ports: + - "5672:5672" + - "15672:15672" # management UI + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + user-service: + <<: *go-service + command: go run ./services/user-service/cmd/main.go + environment: + <<: *go-env + HTTP_ADDR: ":8084" + ports: + - "8084:8084" + depends_on: + postgres: + condition: service_healthy + + product-service: + <<: *go-service + command: go run ./services/product-service/cmd + environment: + <<: *go-env + HTTP_ADDR: ":8082" + PRODUCT_SERVICE_GRPC_ADDR: ":9090" + ports: + - "8082:8082" + - "9090:9090" + depends_on: + postgres: + condition: service_healthy + + cart-service: + <<: *go-service + command: go run ./services/cart-service/cmd + environment: + <<: *go-env + HTTP_ADDR: ":8083" + ports: + - "8083:8083" + depends_on: + postgres: + condition: service_healthy + product-service: + condition: service_started + + order-service: + <<: *go-service + command: go run ./services/order-service/cmd + environment: + <<: *go-env + HTTP_ADDR: ":8085" + ports: + - "8085:8085" + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + cart-service: + condition: service_started + + ai-service: + <<: *go-service + command: go run ./services/ai-service/cmd + environment: + <<: *go-env + HTTP_ADDR: ":8086" + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + AI_MODEL: ${AI_MODEL:-gemini-3.6-flash} + ports: + - "8086:8086" + depends_on: + rabbitmq: + condition: service_healthy + product-service: + condition: service_started + cart-service: + condition: service_started + + api-gateway: + <<: *go-service + command: go run ./services/api-gateway + environment: + <<: *go-env + HTTP_ADDR: ":8081" + SUPABASE_URL: ${SUPABASE_URL:-} + SUPABASE_KEY: ${SUPABASE_KEY:-} + ports: + - "8081:8081" + depends_on: + - user-service + - product-service + - cart-service + - order-service + - ai-service + +volumes: + postgres-data: + go-mod-cache: diff --git a/go.mod b/go.mod index 34ef3fc..3ddd5b6 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,10 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/rabbitmq/amqp091-go v1.14.0 github.com/supabase-community/auth-go v1.4.0 golang.org/x/crypto v0.41.0 + google.golang.org/genai v1.71.0 google.golang.org/grpc v1.75.0 google.golang.org/protobuf v1.36.6 gorm.io/datatypes v1.2.6 @@ -18,6 +20,9 @@ require ( ) require ( + cloud.google.com/go v0.116.0 // indirect + cloud.google.com/go/auth v0.9.3 // indirect + cloud.google.com/go/compute/metadata v0.7.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect @@ -28,6 +33,11 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -43,9 +53,11 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + go.opencensus.io v0.24.0 // indirect golang.org/x/arch v0.19.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/go.sum b/go.sum index 06bdc74..c6f2a0a 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,32 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= +cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= +cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -41,13 +56,40 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -89,6 +131,9 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek= +github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -99,8 +144,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/supabase-community/auth-go v1.4.0 h1:5dS0NB9TKdT3zwaQqHJhrTFXvAcIC2/EDVC9dEV0wnY= github.com/supabase-community/auth-go v1.4.0/go.mod h1:Ww/iTwJNJZh//HwEl0i/vgC2JUgRwU+s3B8HfBqClas= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= @@ -109,6 +154,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= @@ -121,25 +168,76 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU= golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genai v1.71.0 h1:Wfo9n0uSzMhZH7d+rP7QxxSWELEDSD4z6O8W/C9s3oM= +google.golang.org/genai v1.71.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -161,4 +259,6 @@ gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOze gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/infra/development/k8s/ai-service-deployment.yaml b/infra/development/k8s/ai-service-deployment.yaml index 0897637..90b933a 100644 --- a/infra/development/k8s/ai-service-deployment.yaml +++ b/infra/development/k8s/ai-service-deployment.yaml @@ -16,7 +16,7 @@ spec: - name: ai-service image: askshop/ai-service ports: - - containerPort: 8087 + - containerPort: 8086 resources: requests: memory: "64Mi" @@ -33,7 +33,7 @@ spec: selector: app: ai-service ports: - - port: 8087 + - port: 8086 name: http - targetPort: 8087 + targetPort: 8086 type: ClusterIP diff --git a/infra/development/k8s/api-gateway-deployment.yaml b/infra/development/k8s/api-gateway-deployment.yaml index dd2ecdb..8de4ec3 100644 --- a/infra/development/k8s/api-gateway-deployment.yaml +++ b/infra/development/k8s/api-gateway-deployment.yaml @@ -25,11 +25,13 @@ spec: memory: "128Mi" cpu: "125m" env: - # Accessing an env variable from the app-config config map - - name: GATEWAY_HTTP_ADDR + # Accessing an env variable from the app-config config map. Must be + # named HTTP_ADDR to match what services/api-gateway/main.go reads + # (env.GetString("HTTP_ADDR", ...)) — GATEWAY_HTTP_ADDR was a no-op. + - name: HTTP_ADDR valueFrom: configMapKeyRef: - key: GATEWAY_HTTP_ADDR + key: HTTP_ADDR name: app-config --- apiVersion: v1 diff --git a/infra/development/k8s/app-config.yaml b/infra/development/k8s/app-config.yaml index 9ca691f..2eadfe9 100644 --- a/infra/development/k8s/app-config.yaml +++ b/infra/development/k8s/app-config.yaml @@ -4,4 +4,4 @@ metadata: name: app-config data: ENVIRONMENT: "development" - GATEWAY_HTTP_ADDR: ":8081" \ No newline at end of file + HTTP_ADDR: ":8081" \ No newline at end of file diff --git a/infra/development/k8s/cart-service-deployment.yaml b/infra/development/k8s/cart-service-deployment.yaml index 1e3a7f5..20777e7 100644 --- a/infra/development/k8s/cart-service-deployment.yaml +++ b/infra/development/k8s/cart-service-deployment.yaml @@ -16,7 +16,7 @@ spec: - name: cart-service image: askshop/cart-service ports: - - containerPort: 8086 + - containerPort: 8083 resources: requests: memory: "64Mi" @@ -33,7 +33,7 @@ spec: selector: app: cart-service ports: - - port: 8086 + - port: 8083 name: http - targetPort: 8086 + targetPort: 8083 type: ClusterIP diff --git a/infra/development/k8s/product-seeder-job.yaml b/infra/development/k8s/product-seeder-job.yaml index 7a60b96..7477edd 100644 --- a/infra/development/k8s/product-seeder-job.yaml +++ b/infra/development/k8s/product-seeder-job.yaml @@ -7,7 +7,7 @@ spec: spec: containers: - name: seeder - image: golang:1.21 + image: golang:1.24-bookworm command: ["/bin/bash"] args: - -c diff --git a/infra/development/k8s/product-service-deployment.yaml b/infra/development/k8s/product-service-deployment.yaml index c6faf18..ec234f9 100644 --- a/infra/development/k8s/product-service-deployment.yaml +++ b/infra/development/k8s/product-service-deployment.yaml @@ -16,7 +16,10 @@ spec: - name: product-service image: askshop/product-service ports: - - containerPort: 8083 + - containerPort: 8082 + name: http + - containerPort: 9090 + name: grpc resources: requests: memory: "64Mi" @@ -33,7 +36,10 @@ spec: selector: app: product-service ports: - - port: 8083 + - port: 8082 name: http - targetPort: 8083 + targetPort: 8082 + - port: 9090 + name: grpc + targetPort: 9090 type: ClusterIP diff --git a/services/ai-service/cmd/main.go b/services/ai-service/cmd/main.go index 7905807..49eaede 100644 --- a/services/ai-service/cmd/main.go +++ b/services/ai-service/cmd/main.go @@ -1,5 +1,46 @@ package main +import ( + "askshop/services/ai-service/internal/cartclient" + aihttp "askshop/services/ai-service/internal/http" + "askshop/services/ai-service/internal/llm" + "askshop/services/ai-service/internal/productclient" + "askshop/services/ai-service/internal/service" + "askshop/shared/env" + "askshop/shared/events" + "askshop/shared/health" + "log" + + "github.com/gin-gonic/gin" +) + +var httpAddr = env.GetString("HTTP_ADDR", ":8086") + func main() { + log.Println("Starting AI Service") + + if env.GetString("ENV", "development") == "production" { + gin.SetMode(gin.ReleaseMode) + } + + llmClient := llm.New() + if !llmClient.Available { + log.Println("GEMINI_API_KEY not set — AI endpoints will return 503 until it's configured") + } else { + log.Printf("AI Service using model %s", llmClient.Model) + } + + aiService := service.NewAIService(llmClient, productclient.New(), cartclient.New()) + publisher := events.NewPublisher() + defer publisher.Close() + + r := gin.Default() + r.GET("/", func(c *gin.Context) { c.JSON(200, gin.H{"service": "ai", "status": "ok"}) }) + health.Register(r, "ai") + aihttp.NewAIHandler(aiService, publisher).RegisterRoutes(r) + log.Printf("AI Service listening on %s", httpAddr) + if err := r.Run(httpAddr); err != nil { + log.Fatal(err) + } } diff --git a/services/ai-service/internal/cartclient/client.go b/services/ai-service/internal/cartclient/client.go new file mode 100644 index 0000000..6215c69 --- /dev/null +++ b/services/ai-service/internal/cartclient/client.go @@ -0,0 +1,88 @@ +// Package cartclient is a minimal HTTP client for ai-service to read +// abandoned carts from cart-service's admin endpoint, used to generate +// cart-abandonment nudges. +package cartclient + +import ( + "askshop/shared/env" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type envelope struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// CartItem mirrors the subset of cart-service's CartItem fields needed for nudges. +type CartItem struct { + ProductID string `json:"productId"` + ProductName string `json:"productName"` + UnitPrice float64 `json:"unitPrice"` + Quantity int `json:"quantity"` +} + +// Cart mirrors the subset of cart-service's Cart fields needed for nudges. +type Cart struct { + ID string `json:"id"` + UserID string `json:"userId"` + Currency string `json:"currency"` + Items []CartItem `json:"items"` +} + +type Client struct { + baseURL string + httpClient *http.Client +} + +func New() *Client { + baseURL := env.GetString("CART_SERVICE_URL", "http://cart-service:8083") + return &Client{baseURL: baseURL, httpClient: &http.Client{Timeout: 10 * time.Second}} +} + +// ListAbandoned returns carts untouched for at least sinceMinutes. +func (c *Client) ListAbandoned(ctx context.Context, sinceMinutes int) ([]Cart, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%s/api/v1/cart/admin/abandoned?sinceMinutes=%d", c.baseURL, sinceMinutes), nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, fmt.Errorf("cart-service returned unexpected body (status %d): %s", resp.StatusCode, string(raw)) + } + if !env.Success { + msg := "cart-service request failed" + if env.Error != nil && env.Error.Message != "" { + msg = env.Error.Message + } + return nil, fmt.Errorf("%s", msg) + } + + var carts []Cart + if len(env.Data) > 0 { + if err := json.Unmarshal(env.Data, &carts); err != nil { + return nil, err + } + } + return carts, nil +} diff --git a/services/ai-service/internal/http/ai_handler.go b/services/ai-service/internal/http/ai_handler.go new file mode 100644 index 0000000..6dd0ce3 --- /dev/null +++ b/services/ai-service/internal/http/ai_handler.go @@ -0,0 +1,104 @@ +package http + +import ( + "askshop/services/ai-service/internal/productclient" + "askshop/services/ai-service/internal/service" + "askshop/shared/contracts" + "askshop/shared/events" + "askshop/shared/response" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type AIHandler struct { + service *service.AIService + publisher *events.Publisher +} + +func NewAIHandler(svc *service.AIService, publisher *events.Publisher) *AIHandler { + return &AIHandler{service: svc, publisher: publisher} +} + +func (h *AIHandler) RegisterRoutes(router *gin.Engine) { + api := router.Group("/api/v1/ai") + { + api.POST("/chat", h.Chat) + api.POST("/products/:id/explain", h.ExplainProduct) + api.POST("/cart-nudges", h.GenerateCartNudges) + } +} + +func mapError(err error) (int, string, string) { + if errors.Is(err, service.ErrLLMUnavailable) { + return http.StatusServiceUnavailable, contracts.CodeInternalServerError, err.Error() + } + var statusErr *productclient.StatusError + if errors.As(err, &statusErr) && statusErr.StatusCode >= 400 && statusErr.StatusCode < 500 { + // Forward product-service's own 4xx (not found, bad id, etc.) instead + // of collapsing every failure to 500. + return statusErr.StatusCode, contracts.CodeInternalServerError, err.Error() + } + return http.StatusInternalServerError, contracts.CodeInternalServerError, err.Error() +} + +type chatRequest struct { + Message string `json:"message" binding:"required"` + History []service.ChatMessage `json:"history"` +} + +// Chat handles POST /api/v1/ai/chat: the conversational shopping assistant. +func (h *AIHandler) Chat(c *gin.Context) { + var req chatRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid request body", err.Error()) + return + } + + result, err := h.service.Chat(c.Request.Context(), req.History, req.Message) + if err != nil { + status, code, msg := mapError(err) + response.Error(c, status, code, msg, nil) + return + } + response.Success(c, http.StatusOK, result, nil, "") +} + +// ExplainProduct handles POST /api/v1/ai/products/:id/explain. +func (h *AIHandler) ExplainProduct(c *gin.Context) { + productID := c.Param("id") + + explanation, err := h.service.ExplainProduct(c.Request.Context(), productID) + if err != nil { + status, code, msg := mapError(err) + response.Error(c, status, code, msg, nil) + return + } + + if pubErr := h.publisher.Publish(c.Request.Context(), contracts.AICmdExplainProduct, gin.H{"productId": productID}); pubErr != nil { + // Non-fatal: the explanation already succeeded. + _ = pubErr + } + + response.Success(c, http.StatusOK, gin.H{"explanation": explanation}, nil, "") +} + +// GenerateCartNudges handles POST /api/v1/ai/cart-nudges: a batch job +// (intended for cron/manual trigger) that generates re-engagement messages +// for abandoned carts. +func (h *AIHandler) GenerateCartNudges(c *gin.Context) { + minutes, err := strconv.Atoi(c.DefaultQuery("sinceMinutes", "60")) + if err != nil || minutes <= 0 { + minutes = 60 + } + + nudges, err := h.service.GenerateAbandonedCartNudges(c.Request.Context(), minutes) + if err != nil { + status, code, msg := mapError(err) + response.Error(c, status, code, msg, nil) + return + } + response.Success(c, http.StatusOK, nudges, nil, "") +} diff --git a/services/ai-service/internal/llm/client.go b/services/ai-service/internal/llm/client.go new file mode 100644 index 0000000..b13f6d1 --- /dev/null +++ b/services/ai-service/internal/llm/client.go @@ -0,0 +1,50 @@ +// Package llm wraps the official Google Gen AI Go SDK (Gemini) behind a thin +// interface so the rest of ai-service depends on a small surface, not the SDK +// directly, and so the service can start (and degrade cleanly) without an API key. +package llm + +import ( + "askshop/shared/env" + "context" + "log" + + "google.golang.org/genai" +) + +// DefaultModel is used unless AI_MODEL overrides it. "flash" is the +// cost-appropriate default for a chat/search assistant; override via +// AI_MODEL (e.g. "gemini-3.1-pro-preview") for higher-reasoning workloads. +const DefaultModel = "gemini-3.6-flash" + +// Client wraps the Gemini SDK client plus the model this service should use. +type Client struct { + SDK *genai.Client + Model string + Available bool +} + +// New builds a Client. It's always non-nil; Available is false when neither +// GEMINI_API_KEY nor GOOGLE_API_KEY is set, so callers can return a clear 503 +// instead of making a doomed network call. +func New() *Client { + apiKey := env.GetString("GEMINI_API_KEY", env.GetString("GOOGLE_API_KEY", "")) + model := env.GetString("AI_MODEL", DefaultModel) + + client := &Client{Model: model, Available: apiKey != ""} + if !client.Available { + return client + } + + sdk, err := genai.NewClient(context.Background(), &genai.ClientConfig{ + APIKey: apiKey, + Backend: genai.BackendGeminiAPI, + }) + if err != nil { + log.Printf("llm: failed to construct Gemini client, AI features disabled: %v", err) + client.Available = false + return client + } + + client.SDK = sdk + return client +} diff --git a/services/ai-service/internal/productclient/client.go b/services/ai-service/internal/productclient/client.go new file mode 100644 index 0000000..591fae7 --- /dev/null +++ b/services/ai-service/internal/productclient/client.go @@ -0,0 +1,118 @@ +// Package productclient is a minimal HTTP client for ai-service to search and +// fetch products from product-service, so the shopping assistant only ever +// talks about products that actually exist in the catalog. +package productclient + +import ( + "askshop/shared/env" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// StatusError preserves product-service's HTTP status so callers can map +// "not found" (404) and "bad request" (400) distinctly instead of collapsing +// every failure to 500. +type StatusError struct { + StatusCode int + Message string +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("product-service returned %d: %s", e.StatusCode, e.Message) +} + +type envelope struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// Product mirrors the subset of product-service's ProductResponse fields +// ai-service needs to ground its answers in real catalog data. +type Product struct { + ID string `json:"id"` + Name string `json:"name"` + SKU string `json:"sku"` + Description string `json:"description"` + Currency string `json:"currency"` + Status string `json:"status"` + Tags []string `json:"tags"` + PriceCents int64 `json:"priceCents"` + StockQuantity int `json:"stockQuantity"` +} + +type Client struct { + baseURL string + httpClient *http.Client +} + +func New() *Client { + baseURL := env.GetString("PRODUCT_SERVICE_URL", "http://product-service:8082") + return &Client{baseURL: baseURL, httpClient: &http.Client{Timeout: 10 * time.Second}} +} + +func (c *Client) get(ctx context.Context, path string, out interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + return &StatusError{StatusCode: resp.StatusCode, Message: string(raw)} + } + if !env.Success { + msg := "product-service request failed" + if env.Error != nil && env.Error.Message != "" { + msg = env.Error.Message + } + return &StatusError{StatusCode: resp.StatusCode, Message: msg} + } + if out != nil && len(env.Data) > 0 { + return json.Unmarshal(env.Data, out) + } + return nil +} + +// Search does a keyword search over the catalog, capped at limit results. +func (c *Client) Search(ctx context.Context, query string, limit int) ([]Product, error) { + if limit <= 0 { + limit = 10 + } + var products []Product + path := "/api/v1/products?" + url.Values{ + "q": {query}, + "pageSize": {fmt.Sprintf("%d", limit)}, + }.Encode() + if err := c.get(ctx, path, &products); err != nil { + return nil, err + } + return products, nil +} + +// GetByID fetches a single product by id. +func (c *Client) GetByID(ctx context.Context, id string) (*Product, error) { + var p Product + if err := c.get(ctx, "/api/v1/products/"+id, &p); err != nil { + return nil, err + } + return &p, nil +} diff --git a/services/ai-service/internal/service/chat_service.go b/services/ai-service/internal/service/chat_service.go new file mode 100644 index 0000000..0d1338b --- /dev/null +++ b/services/ai-service/internal/service/chat_service.go @@ -0,0 +1,223 @@ +// Package service implements ai-service's business logic: a conversational +// shopping assistant grounded in the real product catalog via function +// calling, plus single-shot product explanations and cart-abandonment nudges. +package service + +import ( + "askshop/services/ai-service/internal/cartclient" + "askshop/services/ai-service/internal/llm" + "askshop/services/ai-service/internal/productclient" + "context" + "errors" + "fmt" + "log" + "strings" + + "google.golang.org/genai" +) + +var ErrLLMUnavailable = errors.New("AI features are unavailable: GEMINI_API_KEY is not configured") + +const maxToolIterations = 4 + +type ChatMessage struct { + Role string `json:"role"` // "user" or "assistant" + Content string `json:"content"` +} + +type ChatResult struct { + Reply string `json:"reply"` + ReferencedSKUs []string `json:"referencedSkus,omitempty"` +} + +// AIService coordinates the LLM with product/cart data so answers stay +// grounded in what's actually in the catalog rather than the model guessing. +type AIService struct { + llm *llm.Client + product *productclient.Client + cart *cartclient.Client +} + +func NewAIService(llmClient *llm.Client, productClient *productclient.Client, cartClient *cartclient.Client) *AIService { + return &AIService{llm: llmClient, product: productClient, cart: cartClient} +} + +const chatSystemPrompt = `You are AskShop's shopping assistant. Help customers find products in +the catalog and answer questions about them. Always use the search_products function to look up +products before recommending or describing anything specific — never invent products, prices, +or stock levels. If nothing in the catalog matches, say so plainly. Keep replies short (2-4 +sentences) and mention prices in dollars (priceCents / 100), formatted like $19.99.` + +var searchProductsDeclaration = &genai.FunctionDeclaration{ + Name: "search_products", + Description: "Search the AskShop product catalog by keyword. Returns matching products with id, name, price, and stock.", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "query": { + Type: genai.TypeString, + Description: "Keywords to search for, e.g. 'wireless headphones'", + }, + }, + Required: []string{"query"}, + }, +} + +// Chat answers a user's shopping question, using search_products as a +// function so the model can ground its reply in the real catalog. +func (s *AIService) Chat(ctx context.Context, history []ChatMessage, userMessage string) (*ChatResult, error) { + if !s.llm.Available { + return nil, ErrLLMUnavailable + } + + contents := make([]*genai.Content, 0, len(history)+1) + for _, m := range history { + role := genai.Role(genai.RoleUser) + if m.Role == "assistant" { + role = genai.Role(genai.RoleModel) + } + contents = append(contents, genai.NewContentFromText(m.Content, role)) + } + contents = append(contents, genai.NewContentFromText(userMessage, genai.RoleUser)) + + config := &genai.GenerateContentConfig{ + SystemInstruction: genai.NewContentFromText(chatSystemPrompt, genai.RoleUser), + Tools: []*genai.Tool{{FunctionDeclarations: []*genai.FunctionDeclaration{searchProductsDeclaration}}}, + } + + referenced := map[string]struct{}{} + var reply string + + for i := 0; i < maxToolIterations; i++ { + resp, err := s.llm.SDK.Models.GenerateContent(ctx, s.llm.Model, contents, config) + if err != nil { + return nil, fmt.Errorf("assistant call failed: %w", err) + } + if len(resp.Candidates) == 0 || resp.Candidates[0].Content == nil { + break + } + contents = append(contents, resp.Candidates[0].Content) + + calls := resp.FunctionCalls() + if len(calls) == 0 { + reply = resp.Text() + break + } + + for _, call := range calls { + result, skus := s.runTool(ctx, call.Name, call.Args) + for _, sku := range skus { + referenced[sku] = struct{}{} + } + contents = append(contents, genai.NewContentFromFunctionResponse(call.Name, map[string]any{"output": result}, genai.RoleUser)) + } + } + + skus := make([]string, 0, len(referenced)) + for sku := range referenced { + skus = append(skus, sku) + } + + reply = strings.TrimSpace(reply) + if reply == "" { + reply = "I couldn't find anything matching that in our catalog right now." + } + return &ChatResult{Reply: reply, ReferencedSKUs: skus}, nil +} + +// runTool executes a function call by name and returns its result text plus +// any product SKUs it surfaced (for the caller to track what was referenced). +func (s *AIService) runTool(ctx context.Context, name string, args map[string]any) (string, []string) { + switch name { + case "search_products": + query, _ := args["query"].(string) + products, err := s.product.Search(ctx, query, 5) + if err != nil { + log.Printf("ai-service: search_products failed: %v", err) + return fmt.Sprintf("search failed: %v", err), nil + } + if len(products) == 0 { + return "no matching products found", nil + } + + var sb strings.Builder + skus := make([]string, 0, len(products)) + for _, p := range products { + fmt.Fprintf(&sb, "- %s (sku=%s, id=%s): $%.2f, %d in stock, status=%s\n", + p.Name, p.SKU, p.ID, float64(p.PriceCents)/100.0, p.StockQuantity, p.Status) + skus = append(skus, p.SKU) + } + return sb.String(), skus + default: + return fmt.Sprintf("unknown function: %s", name), nil + } +} + +// ExplainProduct generates a short, grounded explanation of a single product. +func (s *AIService) ExplainProduct(ctx context.Context, productID string) (string, error) { + if !s.llm.Available { + return "", ErrLLMUnavailable + } + + p, err := s.product.GetByID(ctx, productID) + if err != nil { + return "", fmt.Errorf("failed to look up product: %w", err) + } + + prompt := fmt.Sprintf( + "Explain this product to a shopper in 2-3 sentences, based only on the facts given. "+ + "Name: %s. SKU: %s. Description: %s. Price: $%.2f. Stock: %d units. Status: %s. Tags: %s.", + p.Name, p.SKU, p.Description, float64(p.PriceCents)/100.0, p.StockQuantity, p.Status, strings.Join(p.Tags, ", "), + ) + + resp, err := s.llm.SDK.Models.GenerateContent(ctx, s.llm.Model, genai.Text(prompt), nil) + if err != nil { + return "", fmt.Errorf("assistant call failed: %w", err) + } + return strings.TrimSpace(resp.Text()), nil +} + +type CartNudge struct { + UserID string `json:"userId"` + CartID string `json:"cartId"` + Message string `json:"message"` +} + +// GenerateAbandonedCartNudges fetches carts abandoned for at least +// sinceMinutes and generates a short re-engagement message for each. +func (s *AIService) GenerateAbandonedCartNudges(ctx context.Context, sinceMinutes int) ([]CartNudge, error) { + if !s.llm.Available { + return nil, ErrLLMUnavailable + } + + carts, err := s.cart.ListAbandoned(ctx, sinceMinutes) + if err != nil { + return nil, fmt.Errorf("failed to list abandoned carts: %w", err) + } + + nudges := make([]CartNudge, 0, len(carts)) + for _, c := range carts { + if len(c.Items) == 0 { + continue + } + + var items strings.Builder + for _, item := range c.Items { + fmt.Fprintf(&items, "- %dx %s ($%.2f each)\n", item.Quantity, item.ProductName, item.UnitPrice) + } + + prompt := fmt.Sprintf( + "Write a short (1-2 sentence), friendly cart-abandonment reminder for a shopper who left "+ + "these items in their cart:\n%s\nDo not invent a discount or promotion.", items.String()) + + resp, err := s.llm.SDK.Models.GenerateContent(ctx, s.llm.Model, genai.Text(prompt), nil) + if err != nil { + log.Printf("ai-service: nudge generation failed for cart %s: %v", c.ID, err) + continue + } + + nudges = append(nudges, CartNudge{UserID: c.UserID, CartID: c.ID, Message: strings.TrimSpace(resp.Text())}) + } + + return nudges, nil +} diff --git a/services/api-gateway/main.go b/services/api-gateway/main.go index c6a28b9..b51305c 100644 --- a/services/api-gateway/main.go +++ b/services/api-gateway/main.go @@ -4,7 +4,9 @@ import ( "askshop/services/api-gateway/rest" "askshop/shared/contracts" "askshop/shared/env" + "askshop/shared/health" "askshop/shared/response" + "context" "fmt" "net/http" "strings" @@ -48,10 +50,161 @@ func main() { // Service clients userClient := rest.NewServiceClient("user") + productClient := rest.NewServiceClient("product") + cartClient := rest.NewServiceClient("cart") + orderClient := rest.NewServiceClient("order") + aiClient := rest.NewServiceClient("ai") // API v1 group v1 := router.Group(contracts.Routes.APIBase) + // Products routes (proxied to product-service) + products := v1.Group("/products") + { + products.GET("", func(c *gin.Context) { + var raw []map[string]interface{} + path := "/api/v1/products" + if q := c.Request.URL.RawQuery; q != "" { + path += "?" + q + } + if err := productClient.Get(c.Request.Context(), path, &raw); err != nil { + logger.WithField("error", err.Error()).Error("Error fetching products from product service") + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Error fetching products", msg) + return + } + response.Success(c, http.StatusOK, raw, nil, "") + }) + + products.GET("/:id", func(c *gin.Context) { + id := c.Param("id") + var raw map[string]interface{} + if err := productClient.Get(c.Request.Context(), "/api/v1/products/"+id, &raw); err != nil { + logger.WithField("error", err.Error()).Error("Error fetching product from product service") + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Product not found", msg) + return + } + response.Success(c, http.StatusOK, raw, nil, "") + }) + } + + // Cart & order routes (proxied). userCtx forwards the caller's X-User-ID + // header to downstream services until JWT-based identity is wired through + // the gateway. + userCtx := func(c *gin.Context) context.Context { + return context.WithValue(c.Request.Context(), "user_id", c.GetHeader("X-User-ID")) + } + + cart := v1.Group("/cart") + { + cart.GET("", func(c *gin.Context) { + var raw map[string]interface{} + if err := cartClient.Get(userCtx(c), "/api/v1/cart", &raw); err != nil { + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Error fetching cart", msg) + return + } + response.Success(c, http.StatusOK, raw, nil, "") + }) + cart.POST("/items", func(c *gin.Context) { + var body interface{} + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid request body", err.Error()) + return + } + var raw map[string]interface{} + if err := cartClient.Post(userCtx(c), "/api/v1/cart/items", body, &raw); err != nil { + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Error adding item to cart", msg) + return + } + response.Success(c, http.StatusOK, raw, nil, "Item added to cart") + }) + cart.PUT("/items/:itemId", func(c *gin.Context) { + var body interface{} + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid request body", err.Error()) + return + } + var raw map[string]interface{} + if err := cartClient.Put(userCtx(c), "/api/v1/cart/items/"+c.Param("itemId"), body, &raw); err != nil { + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Error updating cart item", msg) + return + } + response.Success(c, http.StatusOK, raw, nil, "Cart item updated") + }) + cart.DELETE("/items/:itemId", func(c *gin.Context) { + if err := cartClient.Delete(userCtx(c), "/api/v1/cart/items/"+c.Param("itemId"), nil); err != nil { + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Error removing cart item", msg) + return + } + response.Success(c, http.StatusOK, nil, nil, "Item removed from cart") + }) + } + + orders := v1.Group("/orders") + { + orders.POST("", func(c *gin.Context) { + var raw map[string]interface{} + if err := orderClient.Post(userCtx(c), "/api/v1/orders", nil, &raw); err != nil { + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Error placing order", msg) + return + } + response.Success(c, http.StatusCreated, raw, nil, "Order placed successfully") + }) + orders.GET("", func(c *gin.Context) { + var raw []map[string]interface{} + if err := orderClient.Get(userCtx(c), "/api/v1/orders", &raw); err != nil { + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Error fetching orders", msg) + return + } + response.Success(c, http.StatusOK, raw, nil, "") + }) + orders.GET("/:id", func(c *gin.Context) { + var raw map[string]interface{} + if err := orderClient.Get(userCtx(c), "/api/v1/orders/"+c.Param("id"), &raw); err != nil { + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Order not found", msg) + return + } + response.Success(c, http.StatusOK, raw, nil, "") + }) + } + + // AI routes (proxied). The shopping assistant, product explanations, and + // the cart-nudge batch endpoint. + ai := v1.Group("/ai") + { + ai.POST("/chat", func(c *gin.Context) { + var body interface{} + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid request body", err.Error()) + return + } + var raw map[string]interface{} + if err := aiClient.Post(c.Request.Context(), "/api/v1/ai/chat", body, &raw); err != nil { + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Error reaching AI service", msg) + return + } + response.Success(c, http.StatusOK, raw, nil, "") + }) + ai.POST("/products/:id/explain", func(c *gin.Context) { + var raw map[string]interface{} + if err := aiClient.Post(c.Request.Context(), "/api/v1/ai/products/"+c.Param("id")+"/explain", nil, &raw); err != nil { + status, msg := rest.StatusAndMessage(err) + response.Error(c, status, contracts.CodeInternalServerError, "Error reaching AI service", msg) + return + } + response.Success(c, http.StatusOK, raw, nil, "") + }) + } + // Users routes users := v1.Group("/users") auth := v1.Group("/auth") @@ -216,6 +369,8 @@ func main() { }) } + health.Register(router, "api-gateway") + // Add a root route router.GET("/", func(c *gin.Context) { // Get environment to show/hide development-only endpoints diff --git a/services/api-gateway/rest/client.go b/services/api-gateway/rest/client.go index 33fd18b..cce8f5a 100644 --- a/services/api-gateway/rest/client.go +++ b/services/api-gateway/rest/client.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -13,6 +14,33 @@ import ( "time" ) +// ServiceError wraps a downstream service's HTTP error response, preserving +// its status code so callers can forward it instead of collapsing every +// failure to 500. +type ServiceError struct { + StatusCode int + Code string + Message string +} + +func (e *ServiceError) Error() string { + return fmt.Sprintf("service returned %d: %s", e.StatusCode, e.Message) +} + +// StatusAndMessage extracts the downstream HTTP status and message from an +// error returned by ServiceClient, so gateway handlers can forward the real +// status instead of hardcoding 500 for every failure (a validation error +// downstream is not the gateway's fault, and clients need to tell the two +// apart). Non-ServiceError failures (network errors, timeouts, marshaling) +// still default to 500 since there's no real downstream response to relay. +func StatusAndMessage(err error) (int, string) { + var svcErr *ServiceError + if errors.As(err, &svcErr) { + return svcErr.StatusCode, svcErr.Message + } + return http.StatusInternalServerError, err.Error() +} + // ServiceClient is a REST client for service-to-service communication type ServiceClient struct { baseURL string @@ -29,6 +57,14 @@ func NewServiceClient(serviceName string) *ServiceClient { switch strings.ToLower(serviceName) { case "user": port = "8084" // user-service default from its main.go + case "product": + port = "8082" // product-service default from its main.go + case "cart": + port = "8083" // cart-service default from its main.go + case "order": + port = "8085" // order-service default from its main.go + case "ai": + port = "8086" // ai-service default from its main.go } def := fmt.Sprintf("http://%s-service:%s", strings.ToLower(serviceName), port) baseURL := env.GetString(key, def) @@ -91,6 +127,10 @@ func (c *ServiceClient) doRequest(ctx context.Context, method, path string, body if authHeader, ok := ctx.Value("authorization").(string); ok && authHeader != "" { req.Header.Set("Authorization", authHeader) } + // Forward the caller's user id (until JWT-based identity is wired through the gateway) + if userID, ok := ctx.Value("user_id").(string); ok && userID != "" { + req.Header.Set("X-User-ID", userID) + } resp, err := c.httpClient.Do(req) if err != nil { @@ -107,10 +147,10 @@ func (c *ServiceClient) doRequest(ctx context.Context, method, path string, body // Check if status code indicates error if resp.StatusCode >= 400 { var apiError contracts.APIResponse - if err := json.Unmarshal(respBody, &apiError); err != nil { - return fmt.Errorf("service returned status %d: %s", resp.StatusCode, string(respBody)) + if err := json.Unmarshal(respBody, &apiError); err != nil || apiError.Error == nil { + return &ServiceError{StatusCode: resp.StatusCode, Message: string(respBody)} } - return fmt.Errorf("service returned error: %s", apiError.Error.Message) + return &ServiceError{StatusCode: resp.StatusCode, Code: apiError.Error.Code, Message: apiError.Error.Message} } // If no result is expected, return nil diff --git a/services/api-gateway/types.go b/services/api-gateway/types.go index 978d821..2ee208c 100644 --- a/services/api-gateway/types.go +++ b/services/api-gateway/types.go @@ -43,11 +43,16 @@ type AuthResponse struct { RefreshToken string `json:"refreshToken,omitempty"` } -// Product represents a product in the system +// Product represents a product returned from product-service type Product struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Price float64 `json:"price"` - ImageURL string `json:"image_url"` + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + SKU string `json:"sku"` + Description string `json:"description"` + Currency string `json:"currency"` + Status string `json:"status"` + Tags []string `json:"tags"` + PriceCents int64 `json:"priceCents"` + StockQuantity int `json:"stockQuantity"` } diff --git a/services/cart-service/cmd/main.go b/services/cart-service/cmd/main.go index 7905807..73ed811 100644 --- a/services/cart-service/cmd/main.go +++ b/services/cart-service/cmd/main.go @@ -1,5 +1,51 @@ package main +import ( + "askshop/services/cart-service/internal/domain" + productclient "askshop/services/cart-service/internal/infrastructure/grpc" + "askshop/services/cart-service/internal/infrastructure/repository" + "askshop/services/cart-service/internal/infrastructure/rest" + "askshop/services/cart-service/internal/service" + "askshop/shared/db" + "askshop/shared/env" + "askshop/shared/health" + "log" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var httpAddr = env.GetString("HTTP_ADDR", ":8083") + func main() { + log.Println("Starting Cart Service") + + if env.GetString("ENV", "development") == "production" { + gin.SetMode(gin.ReleaseMode) + } + + cfg := db.LoadConfigFromEnv() + gdb := db.MustConnect(cfg, db.WithAutoMigrations(func(db *gorm.DB) error { + return db.AutoMigrate( + &domain.Cart{}, + &domain.CartItem{}, + &domain.SavedItem{}, + ) + })) + + cartRepo := repository.NewPGCartRepository(gdb) + productAddr := env.GetString("PRODUCT_SERVICE_GRPC_ADDR", "product-service:9090") + productClient := productclient.NewGRPCClient(productAddr) + + cartService := service.NewCartService(cartRepo, domain.DefaultCartConfig(), productClient) + + r := gin.Default() + r.GET("/", func(c *gin.Context) { c.JSON(200, gin.H{"service": "cart", "status": "ok"}) }) + health.Register(r, "cart") + rest.NewCartHandler(cartService).RegisterRoutes(r) + log.Printf("Cart Service listening on %s", httpAddr) + if err := r.Run(httpAddr); err != nil { + log.Fatal(err) + } } diff --git a/services/cart-service/internal/domain/cart_logic.go b/services/cart-service/internal/domain/cart_logic.go index 4fbd4a7..845b508 100644 --- a/services/cart-service/internal/domain/cart_logic.go +++ b/services/cart-service/internal/domain/cart_logic.go @@ -11,14 +11,15 @@ import ( // Cart business logic and validation var ( - ErrCartNotFound = errors.New("cart not found") - ErrItemNotFound = errors.New("cart item not found") - ErrInvalidQuantity = errors.New("invalid quantity") - ErrProductNotFound = errors.New("product not found") - ErrCartExpired = errors.New("cart has expired") - ErrEmptyCart = errors.New("cart is empty") - ErrItemNotInCart = errors.New("item not in cart") - ErrDuplicateItem = errors.New("item already exists in cart") + ErrCartNotFound = errors.New("cart not found") + ErrItemNotFound = errors.New("cart item not found") + ErrInvalidQuantity = errors.New("invalid quantity") + ErrProductNotFound = errors.New("product not found") + ErrCartExpired = errors.New("cart has expired") + ErrEmptyCart = errors.New("cart is empty") + ErrItemNotInCart = errors.New("item not in cart") + ErrDuplicateItem = errors.New("item already exists in cart") + ErrInsufficientStock = errors.New("insufficient stock") ) // CartConfig holds configuration for cart behavior diff --git a/services/cart-service/internal/domain/cart_logic_test.go b/services/cart-service/internal/domain/cart_logic_test.go new file mode 100644 index 0000000..052da91 --- /dev/null +++ b/services/cart-service/internal/domain/cart_logic_test.go @@ -0,0 +1,241 @@ +package domain + +import ( + "testing" + "time" + + "github.com/google/uuid" + "gorm.io/datatypes" +) + +func TestCartItemBuilder_Build(t *testing.T) { + config := DefaultCartConfig() + productID := uuid.New() + + t.Run("valid item", func(t *testing.T) { + item, err := NewCartItemBuilder(config). + WithProduct(productID, "SKU-1", "Widget", 19.99). + WithQuantity(2). + Build() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if item.Subtotal != 39.98 { + t.Errorf("Subtotal = %v, want 39.98", item.Subtotal) + } + if item.FinalPrice != 39.98 { + t.Errorf("FinalPrice = %v, want 39.98", item.FinalPrice) + } + }) + + t.Run("missing product id", func(t *testing.T) { + _, err := NewCartItemBuilder(config).WithQuantity(1).Build() + if err != ErrProductNotFound { + t.Errorf("err = %v, want ErrProductNotFound", err) + } + }) + + t.Run("zero quantity", func(t *testing.T) { + _, err := NewCartItemBuilder(config). + WithProduct(productID, "SKU-1", "Widget", 9.99). + WithQuantity(0). + Build() + if err != ErrInvalidQuantity { + t.Errorf("err = %v, want ErrInvalidQuantity", err) + } + }) + + t.Run("quantity exceeds max", func(t *testing.T) { + _, err := NewCartItemBuilder(config). + WithProduct(productID, "SKU-1", "Widget", 9.99). + WithQuantity(config.MaxQuantityPerItem + 1). + Build() + if err == nil { + t.Error("expected error for quantity exceeding maximum, got nil") + } + }) + + t.Run("negative price", func(t *testing.T) { + _, err := NewCartItemBuilder(config). + WithProduct(productID, "SKU-1", "Widget", -1). + WithQuantity(1). + Build() + if err == nil { + t.Error("expected error for negative price, got nil") + } + }) + + t.Run("applies discount", func(t *testing.T) { + item, err := NewCartItemBuilder(config). + WithProduct(productID, "SKU-1", "Widget", 100). + WithQuantity(1). + WithDiscount(0.1). + Build() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if item.DiscountTotal != 10 { + t.Errorf("DiscountTotal = %v, want 10", item.DiscountTotal) + } + if item.FinalPrice != 90 { + t.Errorf("FinalPrice = %v, want 90", item.FinalPrice) + } + }) +} + +func TestCartManager_ValidateAddItem(t *testing.T) { + config := DefaultCartConfig() + config.MaxItemsPerCart = 2 + cm := NewCartManager(config) + productID := uuid.New() + + t.Run("full cart rejected", func(t *testing.T) { + cart := &Cart{Items: []CartItem{{}, {}}} + item := &CartItem{ProductID: uuid.New()} + if err := cm.ValidateAddItem(cart, item); err == nil { + t.Error("expected error for full cart, got nil") + } + }) + + t.Run("expired cart rejected", func(t *testing.T) { + past := time.Now().Add(-time.Hour) + cart := &Cart{ExpiresAt: &past} + item := &CartItem{ProductID: uuid.New()} + if err := cm.ValidateAddItem(cart, item); err != ErrCartExpired { + t.Errorf("err = %v, want ErrCartExpired", err) + } + }) + + t.Run("duplicate item rejected", func(t *testing.T) { + cart := &Cart{Items: []CartItem{{ProductID: productID}}} + item := &CartItem{ProductID: productID} + if err := cm.ValidateAddItem(cart, item); err != ErrDuplicateItem { + t.Errorf("err = %v, want ErrDuplicateItem", err) + } + }) + + t.Run("valid add", func(t *testing.T) { + cart := &Cart{} + item := &CartItem{ProductID: uuid.New()} + if err := cm.ValidateAddItem(cart, item); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) +} + +func TestCartManager_ValidateQuantityUpdate(t *testing.T) { + cm := NewCartManager(DefaultCartConfig()) + + if err := cm.ValidateQuantityUpdate(-1); err != ErrInvalidQuantity { + t.Errorf("err = %v, want ErrInvalidQuantity", err) + } + if err := cm.ValidateQuantityUpdate(1000); err == nil { + t.Error("expected error for quantity exceeding maximum, got nil") + } + if err := cm.ValidateQuantityUpdate(5); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCartManager_CalculateCartSummary(t *testing.T) { + config := DefaultCartConfig() + config.TaxRate = 0.1 + config.FreeShippingMinimum = 100 + config.StandardShippingFee = 5 + cm := NewCartManager(config) + + t.Run("below free shipping threshold", func(t *testing.T) { + cart := &Cart{ + Items: []CartItem{ + {UnitPrice: 10, Quantity: 2}, // subtotal 20 + }, + } + summary := cm.CalculateCartSummary(cart) + if summary.Subtotal != 20 { + t.Errorf("Subtotal = %v, want 20", summary.Subtotal) + } + if summary.TaxTotal != 2 { + t.Errorf("TaxTotal = %v, want 2", summary.TaxTotal) + } + if summary.ShippingTotal != 5 { + t.Errorf("ShippingTotal = %v, want 5", summary.ShippingTotal) + } + if summary.GrandTotal != 27 { + t.Errorf("GrandTotal = %v, want 27", summary.GrandTotal) + } + }) + + t.Run("meets free shipping threshold", func(t *testing.T) { + cart := &Cart{ + Items: []CartItem{ + {UnitPrice: 100, Quantity: 1}, + }, + } + summary := cm.CalculateCartSummary(cart) + if summary.ShippingTotal != 0 { + t.Errorf("ShippingTotal = %v, want 0", summary.ShippingTotal) + } + }) +} + +func TestCartManager_ShouldMergeItems(t *testing.T) { + cm := NewCartManager(DefaultCartConfig()) + productID := uuid.New() + + same := datatypes.JSONMap{"color": "blue"} + a := &CartItem{ProductID: productID, ProductSKU: "SKU-1", Variations: same} + b := &CartItem{ProductID: productID, ProductSKU: "SKU-1", Variations: datatypes.JSONMap{"color": "blue"}} + if !cm.ShouldMergeItems(a, b) { + t.Error("expected items with matching product/SKU/variations to merge") + } + + c := &CartItem{ProductID: productID, ProductSKU: "SKU-1", Variations: datatypes.JSONMap{"color": "red"}} + if cm.ShouldMergeItems(a, c) { + t.Error("expected items with different variations not to merge") + } +} + +func TestCartManager_IsAbandonedCart(t *testing.T) { + cm := NewCartManager(DefaultCartConfig()) + threshold := time.Hour + + t.Run("empty cart is never abandoned", func(t *testing.T) { + cart := &Cart{Status: "active", UpdatedAt: time.Now().Add(-2 * time.Hour)} + if cm.IsAbandonedCart(cart, threshold) { + t.Error("empty cart should not be considered abandoned") + } + }) + + t.Run("recently updated cart is not abandoned", func(t *testing.T) { + cart := &Cart{ + Status: "active", + UpdatedAt: time.Now(), + Items: []CartItem{{ID: uuid.New()}}, + } + if cm.IsAbandonedCart(cart, threshold) { + t.Error("recently updated cart should not be considered abandoned") + } + }) + + t.Run("stale active cart is abandoned", func(t *testing.T) { + cart := &Cart{ + Status: "active", + UpdatedAt: time.Now().Add(-2 * time.Hour), + Items: []CartItem{{ID: uuid.New()}}, + } + if !cm.IsAbandonedCart(cart, threshold) { + t.Error("stale cart with items should be considered abandoned") + } + }) + + t.Run("converted cart is never abandoned", func(t *testing.T) { + cart := &Cart{ + Status: "converted", + UpdatedAt: time.Now().Add(-2 * time.Hour), + Items: []CartItem{{ID: uuid.New()}}, + } + if cm.IsAbandonedCart(cart, threshold) { + t.Error("converted cart should not be considered abandoned") + } + }) +} diff --git a/services/cart-service/internal/domain/cart_test.go b/services/cart-service/internal/domain/cart_test.go new file mode 100644 index 0000000..0a7b97c --- /dev/null +++ b/services/cart-service/internal/domain/cart_test.go @@ -0,0 +1,85 @@ +package domain + +import ( + "testing" + "time" + + "github.com/google/uuid" + "gorm.io/datatypes" +) + +func TestCartItem_CalculateTotals(t *testing.T) { + item := &CartItem{UnitPrice: 25, Quantity: 3, DiscountRate: 0.2} + item.CalculateTotals() + + if item.Subtotal != 75 { + t.Errorf("Subtotal = %v, want 75", item.Subtotal) + } + if item.DiscountTotal != 15 { + t.Errorf("DiscountTotal = %v, want 15", item.DiscountTotal) + } + if item.FinalPrice != 60 { + t.Errorf("FinalPrice = %v, want 60", item.FinalPrice) + } +} + +func TestCart_IsExpired(t *testing.T) { + t.Run("nil expiry never expires", func(t *testing.T) { + cart := &Cart{} + if cart.IsExpired() { + t.Error("cart with no expiry should not be expired") + } + }) + + t.Run("future expiry not expired", func(t *testing.T) { + future := time.Now().Add(time.Hour) + cart := &Cart{ExpiresAt: &future} + if cart.IsExpired() { + t.Error("cart with future expiry should not be expired") + } + }) + + t.Run("past expiry is expired", func(t *testing.T) { + past := time.Now().Add(-time.Hour) + cart := &Cart{ExpiresAt: &past} + if !cart.IsExpired() { + t.Error("cart with past expiry should be expired") + } + }) +} + +func TestCart_IsEmpty(t *testing.T) { + if !(&Cart{}).IsEmpty() { + t.Error("cart with no items should be empty") + } + if (&Cart{Items: []CartItem{{}}}).IsEmpty() { + t.Error("cart with items should not be empty") + } +} + +func TestCart_GetItemByProductID(t *testing.T) { + productID := uuid.New() + otherID := uuid.New() + + cart := &Cart{Items: []CartItem{ + {ProductID: productID, Variations: datatypes.JSONMap{}}, + {ProductID: otherID, Variations: datatypes.JSONMap{"size": "L"}}, + }} + + t.Run("finds matching item with no variations", func(t *testing.T) { + item := cart.GetItemByProductID(productID, datatypes.JSONMap{}) + if item == nil { + t.Fatal("expected to find item, got nil") + } + if item.ProductID != productID { + t.Errorf("ProductID = %v, want %v", item.ProductID, productID) + } + }) + + t.Run("no match for unknown product", func(t *testing.T) { + item := cart.GetItemByProductID(uuid.New(), datatypes.JSONMap{}) + if item != nil { + t.Error("expected nil for unknown product ID") + } + }) +} diff --git a/services/cart-service/internal/infrastructure/grpc/grpc_client.go b/services/cart-service/internal/infrastructure/grpc/grpc_client.go index 878568d..7ae921f 100644 --- a/services/cart-service/internal/infrastructure/grpc/grpc_client.go +++ b/services/cart-service/internal/infrastructure/grpc/grpc_client.go @@ -3,16 +3,33 @@ package productclient import ( "askshop/shared/proto" "context" + "errors" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) +// ProductInfo is the subset of product data cart-service needs for pricing and +// availability checks. +type ProductInfo struct { + ID string + Name string + SKU string + Status string + PriceCents int64 + StockQuantity int32 +} + +// ErrProductUnavailable is returned when a product can't be sold (missing or not active). +var ErrProductUnavailable = errors.New("product unavailable") + // Client defines minimal product lookups over gRPC. type Client interface { // GetProductSKU returns the SKU for a product ID. GetProductSKU(ctx context.Context, productID string) (string, error) + // GetProduct returns pricing/availability info for a product ID. + GetProduct(ctx context.Context, productID string) (*ProductInfo, error) } type GRPCClient struct { @@ -25,24 +42,27 @@ func NewGRPCClient(addr string) *GRPCClient { return &GRPCClient{addr: addr, dialTimeout: 3 * time.Second} } -// GetProductSKU dials the product service and fetches product SKU via gRPC. -func (c *GRPCClient) GetProductSKU(ctx context.Context, productID string) (string, error) { +func (c *GRPCClient) dial(ctx context.Context) (*grpc.ClientConn, error) { dctx, cancel := context.WithTimeout(ctx, c.dialTimeout) defer cancel() - conn, err := grpc.DialContext( + return grpc.DialContext( dctx, c.addr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), ) +} + +// GetProductSKU dials the product service and fetches product SKU via gRPC. +func (c *GRPCClient) GetProductSKU(ctx context.Context, productID string) (string, error) { + conn, err := c.dial(ctx) if err != nil { return "", err } defer conn.Close() client := proto.NewProductServiceClient(conn) - // Build a request by ID; adjust if using slug or sku. req := &proto.ProductRequest{Identifier: &proto.ProductRequest_Id{Id: productID}} resp, err := client.GetProduct(ctx, req) if err != nil { @@ -50,3 +70,32 @@ func (c *GRPCClient) GetProductSKU(ctx context.Context, productID string) (strin } return resp.GetSku(), nil } + +// GetProduct dials the product service and fetches pricing/availability info via gRPC. +func (c *GRPCClient) GetProduct(ctx context.Context, productID string) (*ProductInfo, error) { + conn, err := c.dial(ctx) + if err != nil { + return nil, err + } + defer conn.Close() + + client := proto.NewProductServiceClient(conn) + req := &proto.ProductRequest{Identifier: &proto.ProductRequest_Id{Id: productID}} + resp, err := client.GetProduct(ctx, req) + if err != nil { + return nil, err + } + + info := &ProductInfo{ + ID: resp.GetId(), + Name: resp.GetName(), + SKU: resp.GetSku(), + Status: resp.GetStatus(), + PriceCents: resp.GetPriceCents(), + StockQuantity: resp.GetStockQuantity(), + } + if info.Status != "active" { + return info, ErrProductUnavailable + } + return info, nil +} diff --git a/services/cart-service/internal/infrastructure/repository/pg_cart_repository.go b/services/cart-service/internal/infrastructure/repository/pg_cart_repository.go new file mode 100644 index 0000000..f5228f3 --- /dev/null +++ b/services/cart-service/internal/infrastructure/repository/pg_cart_repository.go @@ -0,0 +1,149 @@ +package repository + +import ( + "askshop/services/cart-service/internal/domain" + "errors" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ensure PGCartRepository satisfies domain.CartRepository at compile time. +var _ domain.CartRepository = (*PGCartRepository)(nil) + +// PGCartRepository implements domain.CartRepository backed by Postgres via GORM. +type PGCartRepository struct { + db *gorm.DB +} + +// NewPGCartRepository creates a new Postgres-backed cart repository. +func NewPGCartRepository(db *gorm.DB) *PGCartRepository { + return &PGCartRepository{db: db} +} + +func (r *PGCartRepository) GetCartByUserID(ctx *gin.Context, userID string) (*domain.Cart, error) { + var cart domain.Cart + if err := r.db.WithContext(ctx).Preload("Items").First(&cart, "user_id = ?", userID).Error; err != nil { + return nil, err + } + return &cart, nil +} + +func (r *PGCartRepository) GetCartBySessionID(ctx *gin.Context, sessionID string) (*domain.Cart, error) { + var cart domain.Cart + if err := r.db.WithContext(ctx).Preload("Items").First(&cart, "session_id = ?", sessionID).Error; err != nil { + return nil, err + } + return &cart, nil +} + +func (r *PGCartRepository) CreateCart(ctx *gin.Context, cart *domain.Cart) error { + return r.db.WithContext(ctx).Create(cart).Error +} + +func (r *PGCartRepository) UpdateCart(ctx *gin.Context, cart *domain.Cart) error { + return r.db.WithContext(ctx).Save(cart).Error +} + +func (r *PGCartRepository) DeleteCart(ctx *gin.Context, cartID uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&domain.Cart{}, "id = ?", cartID).Error +} + +func (r *PGCartRepository) AddItemToCart(ctx *gin.Context, cartID uuid.UUID, item *domain.CartItem) error { + item.CartID = cartID + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *PGCartRepository) UpdateCartItem(ctx *gin.Context, itemID uuid.UUID, quantity int) error { + return r.db.WithContext(ctx).Model(&domain.CartItem{}).Where("id = ?", itemID).Update("quantity", quantity).Error +} + +func (r *PGCartRepository) RemoveCartItem(ctx *gin.Context, itemID uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&domain.CartItem{}, "id = ?", itemID).Error +} + +func (r *PGCartRepository) GetCartItem(ctx *gin.Context, itemID uuid.UUID) (*domain.CartItem, error) { + var item domain.CartItem + if err := r.db.WithContext(ctx).First(&item, "id = ?", itemID).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *PGCartRepository) ClearCartItems(ctx *gin.Context, cartID uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&domain.CartItem{}, "cart_id = ?", cartID).Error +} + +func (r *PGCartRepository) GetCartWithItems(ctx *gin.Context, cartID uuid.UUID) (*domain.Cart, error) { + var cart domain.Cart + if err := r.db.WithContext(ctx).Preload("Items").First(&cart, "id = ?", cartID).Error; err != nil { + return nil, err + } + return &cart, nil +} + +func (r *PGCartRepository) SaveItemForLater(ctx *gin.Context, savedItem *domain.SavedItem) error { + return r.db.WithContext(ctx).Create(savedItem).Error +} + +func (r *PGCartRepository) GetSavedItems(ctx *gin.Context, userID string) ([]domain.SavedItem, error) { + var items []domain.SavedItem + err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("priority DESC, created_at DESC").Find(&items).Error + return items, err +} + +func (r *PGCartRepository) RemoveSavedItem(ctx *gin.Context, userID string, productID uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&domain.SavedItem{}, "user_id = ? AND product_id = ?", userID, productID).Error +} + +func (r *PGCartRepository) MoveSavedItemToCart(ctx *gin.Context, userID string, savedItemID uuid.UUID) error { + var saved domain.SavedItem + if err := r.db.WithContext(ctx).First(&saved, "id = ? AND user_id = ?", savedItemID, userID).Error; err != nil { + return err + } + + var cart domain.Cart + if err := r.db.WithContext(ctx).First(&cart, "user_id = ?", userID).Error; err != nil { + return errors.New("cart not found for user") + } + + item := &domain.CartItem{ + CartID: cart.ID, + ProductID: saved.ProductID, + ProductSKU: saved.ProductSKU, + ProductName: saved.ProductName, + UnitPrice: saved.UnitPrice, + Currency: saved.Currency, + Quantity: 1, + Variations: saved.Variations, + Notes: saved.Notes, + } + if err := r.db.WithContext(ctx).Create(item).Error; err != nil { + return err + } + + return r.db.WithContext(ctx).Delete(&domain.SavedItem{}, "id = ?", savedItemID).Error +} + +func (r *PGCartRepository) GetAbandonedCarts(ctx *gin.Context, since *time.Time) ([]domain.Cart, error) { + var carts []domain.Cart + q := r.db.WithContext(ctx).Where("status = ?", "active") + if since != nil { + q = q.Where("updated_at < ?", *since) + } + err := q.Preload("Items").Find(&carts).Error + return carts, err +} + +func (r *PGCartRepository) GetExpiredCarts(ctx *gin.Context) ([]domain.Cart, error) { + var carts []domain.Cart + err := r.db.WithContext(ctx).Where("expires_at IS NOT NULL AND expires_at < ?", time.Now()).Find(&carts).Error + return carts, err +} + +func (r *PGCartRepository) CleanupExpiredCarts(ctx *gin.Context) (int64, error) { + result := r.db.WithContext(ctx).Where("expires_at IS NOT NULL AND expires_at < ?", time.Now()).Delete(&domain.Cart{}) + return result.RowsAffected, result.Error +} diff --git a/services/cart-service/internal/infrastructure/rest/cart_handler.go b/services/cart-service/internal/infrastructure/rest/cart_handler.go index 87b8647..fb205c5 100644 --- a/services/cart-service/internal/infrastructure/rest/cart_handler.go +++ b/services/cart-service/internal/infrastructure/rest/cart_handler.go @@ -7,7 +7,7 @@ import ( "askshop/shared/response" "errors" "net/http" - "os" + "strconv" "time" "github.com/gin-gonic/gin" @@ -15,85 +15,217 @@ import ( "gorm.io/gorm" ) -type CartResponse struct { - ID string `json:"id"` - UserID string `json:"userId"` - Status string `json:"status" gorm:"type:varchar(20);not null;default:active;check:status IN ('active','abandoned','converted','expired')"` - Currency string `json:"currency" gorm:"type:char(3);not null;default:USD"` - Notes string `json:"notes" gorm:"type:text"` - - // Associations - Items []domain.CartItem `json:"items" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` - - // Session and tracking - SessionID string `json:"sessionId" gorm:"type:varchar(255);index"` // For anonymous carts - IPAddress string `json:"ipAddress" gorm:"type:varchar(45)"` // For analytics - - // Timestamps - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - - // Cart expiry (useful for abandoned cart recovery) - ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` -} - func mapDomainError(err error) (int, string, string, interface{}) { switch { case errors.Is(err, gorm.ErrRecordNotFound): - return http.StatusNotFound, contracts.CodeUserNotFound, gorm.ErrRecordNotFound.Error(), nil + return http.StatusNotFound, contracts.CodeInternalServerError, "Cart not found", nil + case errors.Is(err, domain.ErrCartExpired): + return http.StatusGone, contracts.CodeInternalServerError, "Cart has expired", nil + case errors.Is(err, domain.ErrItemNotFound): + return http.StatusNotFound, contracts.CodeInternalServerError, "Item not found", nil + case errors.Is(err, productclient.ErrProductUnavailable): + return http.StatusConflict, contracts.CodeInternalServerError, err.Error(), nil + case errors.Is(err, domain.ErrInsufficientStock): + return http.StatusConflict, contracts.CodeInternalServerError, err.Error(), nil default: return http.StatusInternalServerError, contracts.CodeInternalServerError, "Internal server error", err.Error() } } type CartHandler struct { - cartService domain.CartInterface + cartService domain.CartService +} + +func NewCartHandler(svc domain.CartService) *CartHandler { + return &CartHandler{cartService: svc} +} + +// RegisterRoutes wires cart endpoints under /api/v1/cart. userID is read from the +// "userId" context key, set by an upstream auth middleware. +func (h *CartHandler) RegisterRoutes(router *gin.Engine) { + api := router.Group("/api/v1") + cart := api.Group("/cart") + { + cart.GET("", h.GetCart) + cart.GET("/summary", h.GetCartSummary) + cart.POST("/items", h.AddItem) + cart.PUT("/items/:itemId", h.UpdateItemQuantity) + cart.DELETE("/items/:itemId", h.RemoveItem) + cart.DELETE("", h.ClearCart) + cart.POST("/checkout/validate", h.ValidateCheckout) + cart.POST("/checkout/complete", h.CompleteCheckout) + // Internal/admin endpoint (no auth gate yet): used by ai-service to + // generate cart-abandonment nudges. Not exposed through the gateway. + cart.GET("/admin/abandoned", h.ListAbandonedCarts) + } +} + +func userID(c *gin.Context) string { + if v, ok := c.Get("userId"); ok { + if s, ok := v.(string); ok { + return s + } + } + return c.GetHeader("X-User-ID") } -func NewCartHandler(svc domain.CartInterface) *CartHandler { - return &CartHandler{ - cartService: svc, +// requireUserID rejects the request with 400 if no user identity is present, +// writing the error response itself. Returns ("", false) in that case so the +// caller can just `if uid, ok := requireUserID(c); !ok { return }`. +// Without this, every caller who omits X-User-ID collides on the same +// empty-string cart. +func requireUserID(c *gin.Context) (string, bool) { + uid := userID(c) + if uid == "" { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "X-User-ID header is required", nil) + return "", false } + return uid, true } -func (h *CartHandler) GetCart(ctx *gin.Context) { - userID := ctx.GetString("userId") - cartResp, err := h.cartService.GetCartByUserID(ctx, userID) +func (h *CartHandler) GetCart(c *gin.Context) { + uid := userID(c) + cart, err := h.cartService.GetOrCreateCart(c, uid, c.Query("sessionId")) if err != nil { status, code, msg, details := mapDomainError(err) - response.Error(ctx, status, code, msg, details) + response.Error(c, status, code, msg, details) return } - response.Success(ctx, http.StatusOK, cartResp, nil, "") + response.Success(c, http.StatusOK, cart, nil, "") } -func (h *CartHandler) AddItemToCart(ctx *gin.Context) { - userID := ctx.GetString("userId") - itemID := ctx.Param("itemId") +func (h *CartHandler) GetCartSummary(c *gin.Context) { + uid := userID(c) + summary, err := h.cartService.GetCartSummary(c, uid) + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, summary, nil, "") +} + +func (h *CartHandler) AddItem(c *gin.Context) { + uid, ok := requireUserID(c) + if !ok { + return + } var req domain.AddItemRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - response.Error(ctx, http.StatusBadRequest, "", "Invalid request body", err.Error()) + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid request body", err.Error()) return } - // Try to fetch SKU via product service gRPC; fall back to deterministic SKU. - productUUID := uuid.MustParse(itemID) - sku := "SKU-" + productUUID.String()[:8] - if addr := os.Getenv("PRODUCT_SERVICE_GRPC_ADDR"); addr != "" { - cli := productclient.NewGRPCClient(addr) - if fetched, err := cli.GetProductSKU(ctx, productUUID.String()); err == nil && fetched != "" { - sku = fetched - } + cart, err := h.cartService.AddItem(c, uid, req.ProductID, req.Quantity, req.Variations) + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, cart, nil, "Item added to cart") +} + +func (h *CartHandler) UpdateItemQuantity(c *gin.Context) { + uid, ok := requireUserID(c) + if !ok { + return + } + itemID, err := uuid.Parse(c.Param("itemId")) + if err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid item id", nil) + return + } + + var req domain.UpdateItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid request body", err.Error()) + return + } + + cart, err := h.cartService.UpdateItemQuantity(c, uid, itemID, req.Quantity) + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, cart, nil, "Cart item updated") +} + +func (h *CartHandler) RemoveItem(c *gin.Context) { + uid, ok := requireUserID(c) + if !ok { + return + } + itemID, err := uuid.Parse(c.Param("itemId")) + if err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid item id", nil) + return } - item := &domain.CartItem{ - ProductID: productUUID, - ProductSKU: sku, + + if err := h.cartService.RemoveItem(c, uid, itemID); err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, nil, nil, "Item removed from cart") +} + +func (h *CartHandler) ClearCart(c *gin.Context) { + uid, ok := requireUserID(c) + if !ok { + return + } + if err := h.cartService.ClearCart(c, uid); err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, nil, nil, "Cart cleared") +} + +// CompleteCheckout marks the caller's cart as converted. It's called by +// order-service once an order has been created from the cart's contents. +func (h *CartHandler) CompleteCheckout(c *gin.Context) { + uid, ok := requireUserID(c) + if !ok { + return + } + if err := h.cartService.ConvertCart(c, uid); err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, nil, nil, "Cart marked as converted") +} + +// ListAbandonedCarts returns active carts untouched for at least sinceMinutes +// (default 60). Used by ai-service to generate re-engagement nudges. +func (h *CartHandler) ListAbandonedCarts(c *gin.Context) { + minutes, err := strconv.Atoi(c.DefaultQuery("sinceMinutes", "60")) + if err != nil || minutes <= 0 { + minutes = 60 + } + since := time.Now().Add(-time.Duration(minutes) * time.Minute) + + carts, err := h.cartService.GetAbandonedCarts(c, since) + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, carts, nil, "") +} + +func (h *CartHandler) ValidateCheckout(c *gin.Context) { + uid, ok := requireUserID(c) + if !ok { + return } - cartResp, err := h.cartService.AddItemToCart(ctx, userID, item) + result, err := h.cartService.ValidateCartForCheckout(c, uid) if err != nil { status, code, msg, details := mapDomainError(err) - response.Error(ctx, status, code, msg, details) + response.Error(c, status, code, msg, details) return } - response.Success(ctx, http.StatusOK, cartResp, nil, "Item added to cart") + response.Success(c, http.StatusOK, result, nil, "") } diff --git a/services/cart-service/internal/service/cart_service.go b/services/cart-service/internal/service/cart_service.go index 9542cb8..5eea52d 100644 --- a/services/cart-service/internal/service/cart_service.go +++ b/services/cart-service/internal/service/cart_service.go @@ -2,6 +2,7 @@ package service import ( "askshop/services/cart-service/internal/domain" + productclient "askshop/services/cart-service/internal/infrastructure/grpc" "fmt" "log" "time" @@ -13,17 +14,19 @@ import ( // CartServiceImpl implements the CartService interface type CartServiceImpl struct { - repo domain.CartRepository - manager *domain.CartManager - config domain.CartConfig + repo domain.CartRepository + manager *domain.CartManager + config domain.CartConfig + productClient productclient.Client } // NewCartService creates a new cart service -func NewCartService(repo domain.CartRepository, config domain.CartConfig) domain.CartService { +func NewCartService(repo domain.CartRepository, config domain.CartConfig, productClient productclient.Client) domain.CartService { return &CartServiceImpl{ - repo: repo, - manager: domain.NewCartManager(config), - config: config, + repo: repo, + manager: domain.NewCartManager(config), + config: config, + productClient: productClient, } } @@ -105,16 +108,24 @@ func (s *CartServiceImpl) AddItem(ctx *gin.Context, userID string, productID uui return nil, err } - // TODO: Get product details from product service - // For now, we'll use placeholder data - productName := fmt.Sprintf("Product %s", productID.String()[:8]) - productSKU := fmt.Sprintf("SKU-%s", productID.String()[:8]) - unitPrice := 29.99 // This should come from product service + // Fetch authoritative product details (name, SKU, price, availability) from product-service. + info, err := s.productClient.GetProduct(ctx, productID.String()) + if err != nil { + if info == nil { + return nil, fmt.Errorf("failed to look up product: %w", err) + } + // Product exists but isn't sellable (e.g. draft/archived). + return nil, fmt.Errorf("%w: %s", productclient.ErrProductUnavailable, info.Name) + } + if int(quantity) > int(info.StockQuantity) { + return nil, fmt.Errorf("%w: only %d units of %s in stock", domain.ErrInsufficientStock, info.StockQuantity, info.Name) + } + unitPrice := float64(info.PriceCents) / 100.0 // Build cart item builder := domain.NewCartItemBuilder(s.config) item, err := builder. - WithProduct(productID, productSKU, productName, unitPrice). + WithProduct(productID, info.SKU, info.Name, unitPrice). WithQuantity(quantity). WithVariations(variations). Build() @@ -148,6 +159,24 @@ func (s *CartServiceImpl) AddItem(ctx *gin.Context, userID string, productID uui return s.repo.GetCartWithItems(ctx, cart.ID) } +// assertItemOwnedByUser verifies that cartID belongs to userID before any +// item-level mutation. Item IDs are opaque UUIDs handed back to clients, so +// without this check any caller who learns another user's item ID (by +// guessing, log exposure, a shared order confirmation, etc.) could read or +// mutate that user's cart. Returns ErrItemNotFound in every failure case +// (rather than distinguishing "not yours" from "doesn't exist") to avoid +// giving an attacker an existence oracle. +func (s *CartServiceImpl) assertItemOwnedByUser(ctx *gin.Context, userID string, cartID uuid.UUID) error { + cart, err := s.repo.GetCartByUserID(ctx, userID) + if err != nil { + return domain.ErrItemNotFound + } + if cart.ID != cartID { + return domain.ErrItemNotFound + } + return nil +} + // UpdateItemQuantity updates the quantity of a cart item func (s *CartServiceImpl) UpdateItemQuantity(ctx *gin.Context, userID string, itemID uuid.UUID, quantity int) (*domain.Cart, error) { // Validate quantity @@ -160,22 +189,32 @@ func (s *CartServiceImpl) UpdateItemQuantity(ctx *gin.Context, userID string, it return nil, s.RemoveItem(ctx, userID, itemID) } - // Update item - if err := s.repo.UpdateCartItem(ctx, itemID, quantity); err != nil { - return nil, fmt.Errorf("failed to update cart item: %w", err) - } - - // Get cart for the item item, err := s.repo.GetCartItem(ctx, itemID) if err != nil { return nil, err } + if err := s.assertItemOwnedByUser(ctx, userID, item.CartID); err != nil { + return nil, err + } + + // Update item + if err := s.repo.UpdateCartItem(ctx, itemID, quantity); err != nil { + return nil, fmt.Errorf("failed to update cart item: %w", err) + } return s.repo.GetCartWithItems(ctx, item.CartID) } // RemoveItem removes an item from the cart func (s *CartServiceImpl) RemoveItem(ctx *gin.Context, userID string, itemID uuid.UUID) error { + item, err := s.repo.GetCartItem(ctx, itemID) + if err != nil { + return fmt.Errorf("failed to remove cart item: %w", err) + } + if err := s.assertItemOwnedByUser(ctx, userID, item.CartID); err != nil { + return err + } + if err := s.repo.RemoveCartItem(ctx, itemID); err != nil { return fmt.Errorf("failed to remove cart item: %w", err) } @@ -218,6 +257,9 @@ func (s *CartServiceImpl) SaveForLater(ctx *gin.Context, userID string, itemID u if err != nil { return err } + if err := s.assertItemOwnedByUser(ctx, userID, item.CartID); err != nil { + return err + } // Create saved item savedItem := &domain.SavedItem{ @@ -361,8 +403,40 @@ func (s *CartServiceImpl) ValidateCartForCheckout(ctx *gin.Context, userID strin return result, nil } - // TODO: Validate product availability and prices - // This would involve calling the product service to check current prices and stock + // Validate product availability and prices against product-service. + for _, item := range cart.Items { + info, err := s.productClient.GetProduct(ctx, item.ProductID.String()) + if err != nil { + if info == nil { + result.IsValid = false + result.Errors = append(result.Errors, fmt.Sprintf("failed to look up product %s: %v", item.ProductName, err)) + continue + } + result.IsValid = false + result.UnavailableItems = append(result.UnavailableItems, item.ProductID) + result.Errors = append(result.Errors, fmt.Sprintf("%s is no longer available", item.ProductName)) + continue + } + + currentPrice := float64(info.PriceCents) / 100.0 + if currentPrice != item.UnitPrice { + result.Warnings = append(result.Warnings, fmt.Sprintf("price for %s changed from %.2f to %.2f", item.ProductName, item.UnitPrice, currentPrice)) + result.PriceChanges = append(result.PriceChanges, domain.CartItemPriceChange{ + ItemID: item.ID, + ProductID: item.ProductID, + ProductName: item.ProductName, + OldPrice: item.UnitPrice, + NewPrice: currentPrice, + Difference: currentPrice - item.UnitPrice, + }) + } + + if item.Quantity > int(info.StockQuantity) { + result.IsValid = false + result.UnavailableItems = append(result.UnavailableItems, item.ProductID) + result.Errors = append(result.Errors, fmt.Sprintf("only %d units of %s in stock", info.StockQuantity, item.ProductName)) + } + } return result, nil } diff --git a/services/order-service/cmd/main.go b/services/order-service/cmd/main.go index 7905807..fb4d2fd 100644 --- a/services/order-service/cmd/main.go +++ b/services/order-service/cmd/main.go @@ -1,5 +1,60 @@ package main +import ( + "askshop/services/order-service/internal/domain" + "askshop/services/order-service/internal/infrastructure/cartclient" + orderhttp "askshop/services/order-service/internal/infrastructure/http" + "askshop/services/order-service/internal/infrastructure/repository" + "askshop/services/order-service/internal/service" + "askshop/shared/contracts" + "askshop/shared/db" + "askshop/shared/env" + "askshop/shared/events" + "askshop/shared/health" + "context" + "log" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var httpAddr = env.GetString("HTTP_ADDR", ":8085") + func main() { + log.Println("Starting Order Service") + + if env.GetString("ENV", "development") == "production" { + gin.SetMode(gin.ReleaseMode) + } + + cfg := db.LoadConfigFromEnv() + gdb := db.MustConnect(cfg, db.WithAutoMigrations(func(db *gorm.DB) error { + return db.AutoMigrate(&domain.Order{}, &domain.OrderItem{}) + })) + + orderRepo := repository.NewPGOrderRepository(gdb) + cartCli := cartclient.New() + publisher := events.NewPublisher() + defer publisher.Close() + + orderService := service.NewOrderService(orderRepo, cartCli, publisher) + + // Notification stub: consumes the events order-service just published, proving + // the RabbitMQ round trip end to end. A real notification service would live + // on its own; this keeps Phase 1's wiring self-contained and demonstrable. + consumeCtx, cancelConsume := context.WithCancel(context.Background()) + defer cancelConsume() + go events.Consume(consumeCtx, "order-service.notify-stub", contracts.OrderEventPlaced, func(body []byte) { + log.Printf("notify-stub: received %s: %s", contracts.OrderEventPlaced, string(body)) + }) + + r := gin.Default() + r.GET("/", func(c *gin.Context) { c.JSON(200, gin.H{"service": "order", "status": "ok"}) }) + health.Register(r, "order") + orderhttp.NewOrderHandler(orderService).RegisterRoutes(r) + log.Printf("Order Service listening on %s", httpAddr) + if err := r.Run(httpAddr); err != nil { + log.Fatal(err) + } } diff --git a/services/order-service/internal/domain/order.go b/services/order-service/internal/domain/order.go new file mode 100644 index 0000000..02c12c5 --- /dev/null +++ b/services/order-service/internal/domain/order.go @@ -0,0 +1,85 @@ +package domain + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +var ( + ErrCartEmpty = errors.New("cart is empty") + ErrCartInvalid = errors.New("cart failed checkout validation") + ErrOrderNotFound = errors.New("order not found") +) + +// Order statuses. Kept as plain strings (validated via a DB check constraint) +// to match the convention used by Cart/Product in this codebase. +const ( + StatusPlaced = "placed" + StatusConfirmed = "confirmed" + StatusCancelled = "cancelled" +) + +// Order represents a placed order, snapshotting cart contents at checkout time. +type Order struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + UserID string `json:"userId" gorm:"type:varchar(255);not null;index"` + Status string `json:"status" gorm:"type:varchar(16);not null;default:placed;check:status IN ('placed','confirmed','cancelled')"` + + Currency string `json:"currency" gorm:"type:char(3);not null;default:USD"` + SubtotalCents int64 `json:"subtotalCents" gorm:"not null;default:0"` + TotalCents int64 `json:"totalCents" gorm:"not null;default:0"` + + Items []OrderItem `json:"items" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// OrderItem is a line item snapshot taken from the cart at checkout time. +type OrderItem struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + OrderID uuid.UUID `json:"orderId" gorm:"type:uuid;not null;index"` + + ProductID uuid.UUID `json:"productId" gorm:"type:uuid;not null;index"` + ProductSKU string `json:"productSku" gorm:"type:varchar(64);not null"` + ProductName string `json:"productName" gorm:"type:varchar(255);not null"` + + UnitPriceCents int64 `json:"unitPriceCents" gorm:"not null"` + Quantity int `json:"quantity" gorm:"not null;check:quantity > 0"` + + CreatedAt time.Time `json:"createdAt"` +} + +func (o *Order) BeforeCreate(tx *gorm.DB) error { + if o.ID == uuid.Nil { + o.ID = uuid.New() + } + return nil +} + +func (i *OrderItem) BeforeCreate(tx *gorm.DB) error { + if i.ID == uuid.Nil { + i.ID = uuid.New() + } + return nil +} + +// OrderRepository defines the persistence contract for orders. +type OrderRepository interface { + CreateOrder(ctx context.Context, order *Order) (*Order, error) + GetOrderByID(ctx context.Context, id uuid.UUID) (*Order, error) + GetOrdersByUserID(ctx context.Context, userID string) ([]*Order, error) + UpdateOrderStatus(ctx context.Context, id uuid.UUID, status string) error +} + +// OrderService defines the business logic for placing and reading orders. +type OrderService interface { + PlaceOrder(ctx context.Context, userID string) (*Order, error) + GetOrder(ctx context.Context, id uuid.UUID) (*Order, error) + GetOrdersForUser(ctx context.Context, userID string) ([]*Order, error) +} diff --git a/services/order-service/internal/infrastructure/cartclient/client.go b/services/order-service/internal/infrastructure/cartclient/client.go new file mode 100644 index 0000000..e48aec4 --- /dev/null +++ b/services/order-service/internal/infrastructure/cartclient/client.go @@ -0,0 +1,128 @@ +// Package cartclient is a minimal HTTP client for order-service to read and +// finalize a user's cart in cart-service. It mirrors the request/response +// envelope used by shared/response.Standard. +package cartclient + +import ( + "askshop/shared/env" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/google/uuid" +) + +type envelope struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// CartItem mirrors the subset of cart-service's CartItem fields order-service needs. +type CartItem struct { + ProductID uuid.UUID `json:"productId"` + ProductSKU string `json:"productSku"` + ProductName string `json:"productName"` + UnitPrice float64 `json:"unitPrice"` + Quantity int `json:"quantity"` +} + +// Cart mirrors the subset of cart-service's Cart fields order-service needs. +type Cart struct { + ID uuid.UUID `json:"id"` + Currency string `json:"currency"` + Items []CartItem `json:"items"` +} + +// ValidationResult mirrors cart-service's CartValidationResult. +type ValidationResult struct { + IsValid bool `json:"isValid"` + Errors []string `json:"errors"` +} + +type Client struct { + baseURL string + httpClient *http.Client +} + +func New() *Client { + baseURL := env.GetString("CART_SERVICE_URL", "http://cart-service:8083") + return &Client{baseURL: baseURL, httpClient: &http.Client{Timeout: 10 * time.Second}} +} + +func (c *Client) do(ctx context.Context, method, path, userID string, body interface{}, out interface{}) error { + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + reqBody = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) + if err != nil { + return err + } + req.Header.Set("X-User-ID", userID) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("cart-service returned unexpected body (status %d): %s", resp.StatusCode, string(raw)) + } + if !env.Success { + msg := "cart-service request failed" + if env.Error != nil && env.Error.Message != "" { + msg = env.Error.Message + } + return fmt.Errorf("%s", msg) + } + if out != nil && len(env.Data) > 0 { + return json.Unmarshal(env.Data, out) + } + return nil +} + +// GetCart fetches the user's current cart. +func (c *Client) GetCart(ctx context.Context, userID string) (*Cart, error) { + var cart Cart + if err := c.do(ctx, http.MethodGet, "/api/v1/cart", userID, nil, &cart); err != nil { + return nil, err + } + return &cart, nil +} + +// ValidateCheckout asks cart-service to validate the cart for checkout +// (stock and price checks against product-service). +func (c *Client) ValidateCheckout(ctx context.Context, userID string) (*ValidationResult, error) { + var result ValidationResult + if err := c.do(ctx, http.MethodPost, "/api/v1/cart/checkout/validate", userID, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CompleteCheckout marks the user's cart as converted after an order is created. +func (c *Client) CompleteCheckout(ctx context.Context, userID string) error { + return c.do(ctx, http.MethodPost, "/api/v1/cart/checkout/complete", userID, nil, nil) +} diff --git a/services/order-service/internal/infrastructure/http/order_handler.go b/services/order-service/internal/infrastructure/http/order_handler.go new file mode 100644 index 0000000..778bd9e --- /dev/null +++ b/services/order-service/internal/infrastructure/http/order_handler.go @@ -0,0 +1,102 @@ +package http + +import ( + "askshop/services/order-service/internal/domain" + "askshop/shared/contracts" + "askshop/shared/response" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type OrderHandler struct { + service domain.OrderService +} + +func NewOrderHandler(service domain.OrderService) *OrderHandler { + return &OrderHandler{service: service} +} + +func (h *OrderHandler) RegisterRoutes(router *gin.Engine) { + api := router.Group("/api/v1") + orders := api.Group("/orders") + { + orders.POST("", h.PlaceOrder) + orders.GET("", h.ListOrders) + orders.GET("/:id", h.GetOrder) + } +} + +func userID(c *gin.Context) string { + if v, ok := c.Get("userId"); ok { + if s, ok := v.(string); ok { + return s + } + } + return c.GetHeader("X-User-ID") +} + +func mapDomainError(err error) (int, string, string, interface{}) { + switch { + case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, domain.ErrOrderNotFound): + return http.StatusNotFound, contracts.CodeInternalServerError, "Order not found", nil + case errors.Is(err, domain.ErrCartEmpty), errors.Is(err, domain.ErrCartInvalid): + return http.StatusUnprocessableEntity, contracts.CodeInternalServerError, err.Error(), nil + default: + return http.StatusInternalServerError, contracts.CodeInternalServerError, "Internal server error", err.Error() + } +} + +// PlaceOrder handles POST /api/v1/orders: creates an order from the caller's cart. +func (h *OrderHandler) PlaceOrder(c *gin.Context) { + uid := userID(c) + if uid == "" { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "X-User-ID header is required", nil) + return + } + + order, err := h.service.PlaceOrder(c.Request.Context(), uid) + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusCreated, order, nil, "Order placed successfully") +} + +// GetOrder handles GET /api/v1/orders/:id. +func (h *OrderHandler) GetOrder(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid order id", nil) + return + } + + order, err := h.service.GetOrder(c.Request.Context(), id) + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, order, nil, "") +} + +// ListOrders handles GET /api/v1/orders for the calling user. +func (h *OrderHandler) ListOrders(c *gin.Context) { + uid := userID(c) + if uid == "" { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "X-User-ID header is required", nil) + return + } + + orders, err := h.service.GetOrdersForUser(c.Request.Context(), uid) + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, orders, nil, "") +} diff --git a/services/order-service/internal/infrastructure/repository/pg_order_repository.go b/services/order-service/internal/infrastructure/repository/pg_order_repository.go new file mode 100644 index 0000000..e4af8dc --- /dev/null +++ b/services/order-service/internal/infrastructure/repository/pg_order_repository.go @@ -0,0 +1,44 @@ +package repository + +import ( + "askshop/services/order-service/internal/domain" + "context" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +var _ domain.OrderRepository = (*PGOrderRepository)(nil) + +type PGOrderRepository struct { + db *gorm.DB +} + +func NewPGOrderRepository(db *gorm.DB) *PGOrderRepository { + return &PGOrderRepository{db: db} +} + +func (r *PGOrderRepository) CreateOrder(ctx context.Context, order *domain.Order) (*domain.Order, error) { + if err := r.db.WithContext(ctx).Create(order).Error; err != nil { + return nil, err + } + return order, nil +} + +func (r *PGOrderRepository) GetOrderByID(ctx context.Context, id uuid.UUID) (*domain.Order, error) { + var order domain.Order + if err := r.db.WithContext(ctx).Preload("Items").First(&order, "id = ?", id).Error; err != nil { + return nil, err + } + return &order, nil +} + +func (r *PGOrderRepository) GetOrdersByUserID(ctx context.Context, userID string) ([]*domain.Order, error) { + var orders []*domain.Order + err := r.db.WithContext(ctx).Preload("Items").Where("user_id = ?", userID).Order("created_at DESC").Find(&orders).Error + return orders, err +} + +func (r *PGOrderRepository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status string) error { + return r.db.WithContext(ctx).Model(&domain.Order{}).Where("id = ?", id).Update("status", status).Error +} diff --git a/services/order-service/internal/service/order_service.go b/services/order-service/internal/service/order_service.go new file mode 100644 index 0000000..8d87588 --- /dev/null +++ b/services/order-service/internal/service/order_service.go @@ -0,0 +1,90 @@ +package service + +import ( + "askshop/services/order-service/internal/domain" + "askshop/services/order-service/internal/infrastructure/cartclient" + "askshop/shared/contracts" + "askshop/shared/events" + "context" + "fmt" + "log" + "strings" + + "github.com/google/uuid" +) + +// OrderServiceImpl implements domain.OrderService, coordinating with cart-service +// over HTTP and publishing order lifecycle events over RabbitMQ. +type OrderServiceImpl struct { + repo domain.OrderRepository + cartCli *cartclient.Client + publisher *events.Publisher +} + +func NewOrderService(repo domain.OrderRepository, cartCli *cartclient.Client, publisher *events.Publisher) domain.OrderService { + return &OrderServiceImpl{repo: repo, cartCli: cartCli, publisher: publisher} +} + +// PlaceOrder validates the user's cart, snapshots it into an Order, marks the +// cart as converted, and publishes an order.event.placed event. +func (s *OrderServiceImpl) PlaceOrder(ctx context.Context, userID string) (*domain.Order, error) { + validation, err := s.cartCli.ValidateCheckout(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to validate cart: %w", err) + } + if !validation.IsValid { + return nil, fmt.Errorf("%w: %s", domain.ErrCartInvalid, strings.Join(validation.Errors, "; ")) + } + + cart, err := s.cartCli.GetCart(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to load cart: %w", err) + } + if len(cart.Items) == 0 { + return nil, domain.ErrCartEmpty + } + + order := &domain.Order{ + ID: uuid.New(), + UserID: userID, + Status: domain.StatusPlaced, + Currency: cart.Currency, + } + for _, item := range cart.Items { + unitPriceCents := int64(item.UnitPrice*100 + 0.5) + order.Items = append(order.Items, domain.OrderItem{ + ProductID: item.ProductID, + ProductSKU: item.ProductSKU, + ProductName: item.ProductName, + UnitPriceCents: unitPriceCents, + Quantity: item.Quantity, + }) + order.SubtotalCents += unitPriceCents * int64(item.Quantity) + } + order.TotalCents = order.SubtotalCents + + created, err := s.repo.CreateOrder(ctx, order) + if err != nil { + return nil, fmt.Errorf("failed to create order: %w", err) + } + + if err := s.cartCli.CompleteCheckout(ctx, userID); err != nil { + // The order is already durably created; log and continue rather than + // failing the whole checkout over cart bookkeeping. + log.Printf("order-service: failed to mark cart converted for user %s: %v", userID, err) + } + + if err := s.publisher.Publish(ctx, contracts.OrderEventPlaced, created); err != nil { + log.Printf("order-service: failed to publish %s for order %s: %v", contracts.OrderEventPlaced, created.ID, err) + } + + return created, nil +} + +func (s *OrderServiceImpl) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) { + return s.repo.GetOrderByID(ctx, id) +} + +func (s *OrderServiceImpl) GetOrdersForUser(ctx context.Context, userID string) ([]*domain.Order, error) { + return s.repo.GetOrdersByUserID(ctx, userID) +} diff --git a/services/product-service/cmd/main.go b/services/product-service/cmd/main.go index 4523120..3a8d230 100644 --- a/services/product-service/cmd/main.go +++ b/services/product-service/cmd/main.go @@ -1,14 +1,16 @@ package main import ( - "askshop/services/product-service/internal/domain" - grpc_server "askshop/services/product-service/internal/infrastructure/grpc" - "askshop/services/product-service/internal/infrastructure/repository" - "askshop/services/product-service/internal/service" - "askshop/shared/db" - "askshop/shared/env" - "log" - "strings" + "askshop/services/product-service/internal/domain" + grpc_server "askshop/services/product-service/internal/infrastructure/grpc" + producthttp "askshop/services/product-service/internal/infrastructure/http" + "askshop/services/product-service/internal/infrastructure/repository" + "askshop/services/product-service/internal/service" + "askshop/shared/db" + "askshop/shared/env" + "askshop/shared/health" + "log" + "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -28,52 +30,44 @@ func main() { // Initialize repository: connect DB and prepare migrations cfg := db.LoadConfigFromEnv() - _, err := db.Connect(cfg, db.WithAutoMigrations(func(db *gorm.DB) error { + gdb, err := db.Connect(cfg, db.WithAutoMigrations(func(db *gorm.DB) error { return db.AutoMigrate( &domain.Product{}, &domain.ProductImage{}, &domain.Category{}, ) })) + + var productRepo repository.ProductRepository if err != nil { - log.Printf("DB connection failed: %v", err) + log.Printf("DB connection failed, falling back to in-memory repository: %v", err) + productRepo = repository.NewInMemoryProductRepository() + } else { + log.Println("DB connected; using Postgres repository") + productRepo = repository.NewPGProductRepository(gdb) } - // Set up repository (in-memory until DB repo is implemented) - var productRepo repository.ProductRepository = repository.NewInMemoryProductRepository() - if err != nil { - log.Println("Running with in-memory repository (DB unavailable)") - } else { - log.Println("DB connected; using in-memory repository until PG repo is added") - } - // Initialize service productService := service.NewProductService(productRepo) - // TODO Initialize gRPC server - grpcAddr := env.GetString("PRODUCT_SERVICE_GRPC_ADDR", ":9090") - // If env contains a host like "product-service:9090", bind to local port only - if strings.HasPrefix(grpcAddr, ":") == false { - if idx := strings.LastIndex(grpcAddr, ":"); idx != -1 && idx+1 < len(grpcAddr) { - grpcAddr = ":" + grpcAddr[idx+1:] - } - } - grpcSrv := grpc_server.NewGRPCServer(grpcAddr, productService) - go grpcSrv.Start() - // TODO Initialize HTTP handler - - // TODO Create Gin router - - //TODO Add CORS middleware - - // TODO Register routes + // gRPC server (for service-to-service calls, e.g. cart/order pricing lookups) + grpcAddr := env.GetString("PRODUCT_SERVICE_GRPC_ADDR", ":9090") + // If env contains a host like "product-service:9090", bind to local port only + if !strings.HasPrefix(grpcAddr, ":") { + if idx := strings.LastIndex(grpcAddr, ":"); idx != -1 && idx+1 < len(grpcAddr) { + grpcAddr = ":" + grpcAddr[idx+1:] + } + } + grpcSrv := grpc_server.NewGRPCServer(grpcAddr, productService) + go grpcSrv.Start() - // TODO Add a root route + // HTTP server + r := gin.Default() + r.GET("/", func(c *gin.Context) { c.JSON(200, gin.H{"service": "product", "status": "ok"}) }) + health.Register(r, "product") + producthttp.NewProductHandler(productService).RegisterRoutes(r) - // Start a minimal HTTP server to keep the process alive - r := gin.Default() - r.GET("/", func(c *gin.Context) { c.JSON(200, gin.H{"service": "product", "status": "ok"}) }) - if err := r.Run(httpAddr); err != nil { - log.Fatal(err) - } + if err := r.Run(httpAddr); err != nil { + log.Fatal(err) + } } diff --git a/services/product-service/internal/domain/product.go b/services/product-service/internal/domain/product.go index 47475d7..6fe5b08 100644 --- a/services/product-service/internal/domain/product.go +++ b/services/product-service/internal/domain/product.go @@ -34,6 +34,10 @@ type Product struct { Status string `json:"status" gorm:"type:varchar(16);not null;default:draft;check:status IN ('draft','active','archived')"` Tags datatypes.JSONSlice[string] `json:"tags" gorm:"type:jsonb;default:'[]'"` // e.g., ["new","sale"] + // Pricing & inventory + PriceCents int64 `json:"priceCents" gorm:"not null;default:0"` // unit price in minor currency units (e.g. cents) + StockQuantity int `json:"stockQuantity" gorm:"not null;default:0"` + // Associations Images []ProductImage `json:"images" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` Categories []Category `json:"categories" gorm:"many2many:product_categories;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` diff --git a/services/product-service/internal/domain/product_test.go b/services/product-service/internal/domain/product_test.go new file mode 100644 index 0000000..31869a7 --- /dev/null +++ b/services/product-service/internal/domain/product_test.go @@ -0,0 +1,65 @@ +package domain + +import "testing" + +func TestSlugify(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"simple name", "iPhone 15 Pro", "iphone-15-pro"}, + {"already lowercase", "wireless mouse", "wireless-mouse"}, + {"punctuation collapses to dashes", "Sony WH-1000XM5 Headphones!", "sony-wh-1000xm5-headphones"}, + {"leading and trailing spaces trimmed", " Denim Jacket ", "denim-jacket"}, + {"repeated separators collapse", "A---B C", "a-b-c"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := slugify(tt.in); got != tt.want { + t.Errorf("slugify(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestSlugify_EmptyInputGeneratesUUID(t *testing.T) { + got := slugify(" ") + if len(got) != 36 { + t.Errorf("slugify(empty) = %q, want a UUID-shaped fallback", got) + } +} + +func TestProduct_BeforeCreate(t *testing.T) { + p := &Product{Name: "Wireless Gaming Mouse"} + if err := p.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.ID.String() == "00000000-0000-0000-0000-000000000000" { + t.Error("expected ID to be generated") + } + if p.Slug != "wireless-gaming-mouse" { + t.Errorf("Slug = %q, want %q", p.Slug, "wireless-gaming-mouse") + } +} + +func TestProduct_BeforeCreate_PreservesExplicitSlug(t *testing.T) { + p := &Product{Name: "Wireless Gaming Mouse", Slug: "custom-slug"} + if err := p.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Slug != "custom-slug" { + t.Errorf("Slug = %q, want explicit slug to be preserved", p.Slug) + } +} + +func TestCategory_BeforeCreate(t *testing.T) { + c := &Category{Name: "Home & Garden"} + if err := c.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.Slug != "home-garden" { + t.Errorf("Slug = %q, want %q", c.Slug, "home-garden") + } +} diff --git a/services/product-service/internal/domain/service.go b/services/product-service/internal/domain/service.go index a127779..3e1e388 100644 --- a/services/product-service/internal/domain/service.go +++ b/services/product-service/internal/domain/service.go @@ -1,12 +1,21 @@ package domain -import "context" +import ( + "context" + + "github.com/google/uuid" +) type ProductService interface { - GetProducts(ctx context.Context) ([]*Product, error) - GetProductByID(ctx context.Context, id string) (*Product, error) - GetProductsByCategory(ctx context.Context, category string) ([]*Product, error) - // Pagination - GetProductsPage(ctx context.Context, page, pageSize int) ([]*Product, int64, error) - GetProductsByCategoryPage(ctx context.Context, category string, page, pageSize int) ([]*Product, int64, error) + GetProducts(ctx context.Context) ([]*Product, error) + GetProductByID(ctx context.Context, id string) (*Product, error) + GetProductsByCategory(ctx context.Context, category string) ([]*Product, error) + // Pagination + GetProductsPage(ctx context.Context, page, pageSize int) ([]*Product, int64, error) + GetProductsByCategoryPage(ctx context.Context, category string, page, pageSize int) ([]*Product, int64, error) + SearchProducts(ctx context.Context, query string, page, pageSize int) ([]*Product, int64, error) + // Write operations + CreateProduct(ctx context.Context, product *Product) (*Product, error) + UpdateProduct(ctx context.Context, product *Product) (*Product, error) + DeleteProduct(ctx context.Context, productID uuid.UUID) error } diff --git a/services/product-service/internal/infrastructure/grpc/grpc_server.go b/services/product-service/internal/infrastructure/grpc/grpc_server.go index db1f3c4..3ca8543 100644 --- a/services/product-service/internal/infrastructure/grpc/grpc_server.go +++ b/services/product-service/internal/infrastructure/grpc/grpc_server.go @@ -42,16 +42,18 @@ func toProtoProduct(p *domain.Product) *proto.Product { return nil } out := &proto.Product{ - Id: p.ID.String(), - Name: p.Name, - Slug: p.Slug, - Sku: p.SKU, - Description: p.Description, - Currency: p.Currency, - Status: p.Status, - Tags: append([]string(nil), p.Tags...), - CreatedAt: timestamppb.New(p.CreatedAt), - UpdatedAt: timestamppb.New(p.UpdatedAt), + Id: p.ID.String(), + Name: p.Name, + Slug: p.Slug, + Sku: p.SKU, + Description: p.Description, + Currency: p.Currency, + Status: p.Status, + Tags: append([]string(nil), p.Tags...), + PriceCents: p.PriceCents, + StockQuantity: int32(p.StockQuantity), + CreatedAt: timestamppb.New(p.CreatedAt), + UpdatedAt: timestamppb.New(p.UpdatedAt), } // Images and Categories omitted for brevity return out diff --git a/services/product-service/internal/infrastructure/http/product_handler.go b/services/product-service/internal/infrastructure/http/product_handler.go index d02cfda..5dd3689 100644 --- a/services/product-service/internal/infrastructure/http/product_handler.go +++ b/services/product-service/internal/infrastructure/http/product_handler.go @@ -1 +1,241 @@ package http + +import ( + "askshop/services/product-service/internal/domain" + "askshop/shared/contracts" + "askshop/shared/response" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ProductResponse is the wire format returned to clients. +type ProductResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + SKU string `json:"sku"` + Description string `json:"description"` + Currency string `json:"currency"` + Status string `json:"status"` + Tags []string `json:"tags"` + PriceCents int64 `json:"priceCents"` + StockQuantity int `json:"stockQuantity"` +} + +func toProductResponse(p *domain.Product) ProductResponse { + return ProductResponse{ + ID: p.ID.String(), + Name: p.Name, + Slug: p.Slug, + SKU: p.SKU, + Description: p.Description, + Currency: p.Currency, + Status: p.Status, + Tags: append([]string(nil), p.Tags...), + PriceCents: p.PriceCents, + StockQuantity: p.StockQuantity, + } +} + +// CreateProductRequest is the payload for creating a product. +type CreateProductRequest struct { + Name string `json:"name" binding:"required"` + SKU string `json:"sku" binding:"required"` + Description string `json:"description"` + Currency string `json:"currency"` + Status string `json:"status"` + Tags []string `json:"tags"` + PriceCents int64 `json:"priceCents" binding:"min=0"` + StockQuantity int `json:"stockQuantity" binding:"min=0"` +} + +// UpdateProductRequest is the payload for updating a product. Zero-value fields are +// left as sent (this is a full-replace update, not a partial patch). +type UpdateProductRequest struct { + Name string `json:"name" binding:"required"` + SKU string `json:"sku" binding:"required"` + Description string `json:"description"` + Currency string `json:"currency"` + Status string `json:"status"` + Tags []string `json:"tags"` + PriceCents int64 `json:"priceCents" binding:"min=0"` + StockQuantity int `json:"stockQuantity" binding:"min=0"` +} + +type ProductHandler struct { + service domain.ProductService +} + +func NewProductHandler(service domain.ProductService) *ProductHandler { + return &ProductHandler{service: service} +} + +// RegisterRoutes wires product endpoints under /api/v1/products. +func (h *ProductHandler) RegisterRoutes(router *gin.Engine) { + api := router.Group("/api/v1") + products := api.Group("/products") + { + products.GET("", h.ListProducts) + products.POST("", h.CreateProduct) + products.GET("/:id", h.GetProduct) + products.PUT("/:id", h.UpdateProduct) + products.DELETE("/:id", h.DeleteProduct) + } +} + +func mapDomainError(err error) (int, string, string, interface{}) { + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + return http.StatusNotFound, contracts.CodeInternalServerError, "Product not found", nil + default: + return http.StatusInternalServerError, contracts.CodeInternalServerError, "Internal server error", err.Error() + } +} + +// ListProducts handles GET /api/v1/products, optionally filtered by category and paginated. +func (h *ProductHandler) ListProducts(c *gin.Context) { + category := c.Query("category") + query := c.Query("q") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + if pageSize <= 0 { + pageSize = 20 + } + + var ( + products []*domain.Product + total int64 + err error + ) + switch { + case query != "": + products, total, err = h.service.SearchProducts(c.Request.Context(), query, page, pageSize) + case category != "": + products, total, err = h.service.GetProductsByCategoryPage(c.Request.Context(), category, page, pageSize) + default: + products, total, err = h.service.GetProductsPage(c.Request.Context(), page, pageSize) + } + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + + out := make([]ProductResponse, 0, len(products)) + for _, p := range products { + out = append(out, toProductResponse(p)) + } + response.Success(c, http.StatusOK, out, gin.H{"total": total, "page": page, "pageSize": pageSize}, "") +} + +// GetProduct handles GET /api/v1/products/:id. +func (h *ProductHandler) GetProduct(c *gin.Context) { + id := c.Param("id") + if _, err := uuid.Parse(id); err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid product id", nil) + return + } + + product, err := h.service.GetProductByID(c.Request.Context(), id) + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, toProductResponse(product), nil, "") +} + +// CreateProduct handles POST /api/v1/products. +func (h *ProductHandler) CreateProduct(c *gin.Context) { + var req CreateProductRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid request body", err.Error()) + return + } + + currency := req.Currency + if currency == "" { + currency = "USD" + } + status := req.Status + if status == "" { + status = "draft" + } + + product := &domain.Product{ + Name: req.Name, + SKU: req.SKU, + Description: req.Description, + Currency: currency, + Status: status, + Tags: req.Tags, + PriceCents: req.PriceCents, + StockQuantity: req.StockQuantity, + } + + created, err := h.service.CreateProduct(c.Request.Context(), product) + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusCreated, toProductResponse(created), nil, "Product created successfully") +} + +// UpdateProduct handles PUT /api/v1/products/:id. +func (h *ProductHandler) UpdateProduct(c *gin.Context) { + idParam := c.Param("id") + id, err := uuid.Parse(idParam) + if err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid product id", nil) + return + } + + var req UpdateProductRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid request body", err.Error()) + return + } + + product := &domain.Product{ + ID: id, + Name: req.Name, + SKU: req.SKU, + Description: req.Description, + Currency: req.Currency, + Status: req.Status, + Tags: req.Tags, + PriceCents: req.PriceCents, + StockQuantity: req.StockQuantity, + } + + updated, err := h.service.UpdateProduct(c.Request.Context(), product) + if err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, toProductResponse(updated), nil, "Product updated successfully") +} + +// DeleteProduct handles DELETE /api/v1/products/:id. +func (h *ProductHandler) DeleteProduct(c *gin.Context) { + idParam := c.Param("id") + id, err := uuid.Parse(idParam) + if err != nil { + response.Error(c, http.StatusBadRequest, contracts.CodeInvalidRequestBody, "Invalid product id", nil) + return + } + + if err := h.service.DeleteProduct(c.Request.Context(), id); err != nil { + status, code, msg, details := mapDomainError(err) + response.Error(c, status, code, msg, details) + return + } + response.Success(c, http.StatusOK, nil, nil, "Product deleted successfully") +} diff --git a/services/product-service/internal/infrastructure/repository/in_memory_product_repository.go b/services/product-service/internal/infrastructure/repository/in_memory_product_repository.go index 5a59f15..16a3f10 100644 --- a/services/product-service/internal/infrastructure/repository/in_memory_product_repository.go +++ b/services/product-service/internal/infrastructure/repository/in_memory_product_repository.go @@ -1,53 +1,166 @@ package repository import ( - "askshop/services/product-service/internal/domain" - "context" - "errors" - "time" + "askshop/services/product-service/internal/domain" + "context" + "errors" + "strings" + "sync" + "time" - "github.com/google/uuid" + "github.com/google/uuid" ) -type InMemoryProductRepository struct{} +// ProductRepository defines the data access interface for product operations. +type ProductRepository interface { + GetProductByID(ctx context.Context, productID string) (*domain.Product, error) + GetProductByCategory(ctx context.Context, category string) ([]*domain.Product, error) + GetAllProducts(ctx context.Context) ([]*domain.Product, error) + // Paginated variants return items and total count + GetAllProductsPage(ctx context.Context, limit, offset int) ([]*domain.Product, int64, error) + GetProductByCategoryPage(ctx context.Context, category string, limit, offset int) ([]*domain.Product, int64, error) + // SearchProducts does a keyword search over name/slug/sku (see domain.ScopeSearch). + SearchProducts(ctx context.Context, query string, limit, offset int) ([]*domain.Product, int64, error) + // Write operations + CreateProduct(ctx context.Context, product *domain.Product) (*domain.Product, error) + UpdateProduct(ctx context.Context, product *domain.Product) (*domain.Product, error) + DeleteProduct(ctx context.Context, productID uuid.UUID) error +} + +var ErrNotImplemented = errors.New("not implemented") +var ErrProductNotFound = errors.New("product not found") + +// InMemoryProductRepository is a minimal, non-persistent fallback used when Postgres +// is unavailable. It's not category-aware and exists to keep the service usable in dev. +type InMemoryProductRepository struct { + mu sync.RWMutex + products map[uuid.UUID]*domain.Product +} -func NewInMemoryProductRepository() *InMemoryProductRepository { return &InMemoryProductRepository{} } +func NewInMemoryProductRepository() *InMemoryProductRepository { + return &InMemoryProductRepository{products: make(map[uuid.UUID]*domain.Product)} +} func (r *InMemoryProductRepository) GetProductByID(ctx context.Context, productID string) (*domain.Product, error) { - id, err := uuid.Parse(productID) - if err != nil { - return nil, err - } - // Minimal deterministic mock to enable cross-service integration - now := time.Now() - sku := "SKU-" + id.String()[:8] - return &domain.Product{ - ID: id, - Name: "Product " + id.String()[:8], - Slug: id.String(), - SKU: sku, - Description: "", - Currency: "USD", - Status: "active", - CreatedAt: now, - UpdatedAt: now, - }, nil + id, err := uuid.Parse(productID) + if err != nil { + return nil, err + } + + r.mu.RLock() + if p, ok := r.products[id]; ok { + r.mu.RUnlock() + return p, nil + } + r.mu.RUnlock() + + // Minimal deterministic mock to enable cross-service integration when nothing + // has been created yet (e.g. local dev without seeding). + now := time.Now() + sku := "SKU-" + id.String()[:8] + return &domain.Product{ + ID: id, + Name: "Product " + id.String()[:8], + Slug: id.String(), + SKU: sku, + Description: "", + Currency: "USD", + Status: "active", + PriceCents: 2999, + CreatedAt: now, + UpdatedAt: now, + }, nil } func (r *InMemoryProductRepository) GetProductByCategory(ctx context.Context, category string) ([]*domain.Product, error) { - return []*domain.Product{}, nil + return []*domain.Product{}, nil } func (r *InMemoryProductRepository) GetAllProducts(ctx context.Context) ([]*domain.Product, error) { - return []*domain.Product{}, nil + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*domain.Product, 0, len(r.products)) + for _, p := range r.products { + out = append(out, p) + } + return out, nil } func (r *InMemoryProductRepository) GetAllProductsPage(ctx context.Context, limit, offset int) ([]*domain.Product, int64, error) { - return []*domain.Product{}, 0, nil + all, _ := r.GetAllProducts(ctx) + total := int64(len(all)) + if offset >= len(all) { + return []*domain.Product{}, total, nil + } + end := offset + limit + if end > len(all) { + end = len(all) + } + return all[offset:end], total, nil } func (r *InMemoryProductRepository) GetProductByCategoryPage(ctx context.Context, category string, limit, offset int) ([]*domain.Product, int64, error) { - return []*domain.Product{}, 0, nil + return []*domain.Product{}, 0, nil } -var ErrNotImplemented = errors.New("not implemented") +func (r *InMemoryProductRepository) SearchProducts(ctx context.Context, query string, limit, offset int) ([]*domain.Product, int64, error) { + q := strings.ToLower(strings.TrimSpace(query)) + all, _ := r.GetAllProducts(ctx) + if q == "" { + return all, int64(len(all)), nil + } + + matched := make([]*domain.Product, 0, len(all)) + for _, p := range all { + if strings.Contains(strings.ToLower(p.Name), q) || + strings.Contains(strings.ToLower(p.Slug), q) || + strings.Contains(strings.ToLower(p.SKU), q) { + matched = append(matched, p) + } + } + + total := int64(len(matched)) + if offset >= len(matched) { + return []*domain.Product{}, total, nil + } + end := offset + limit + if end > len(matched) { + end = len(matched) + } + return matched[offset:end], total, nil +} + +func (r *InMemoryProductRepository) CreateProduct(ctx context.Context, product *domain.Product) (*domain.Product, error) { + if product.ID == uuid.Nil { + product.ID = uuid.New() + } + now := time.Now() + product.CreatedAt = now + product.UpdatedAt = now + + r.mu.Lock() + defer r.mu.Unlock() + r.products[product.ID] = product + return product, nil +} + +func (r *InMemoryProductRepository) UpdateProduct(ctx context.Context, product *domain.Product) (*domain.Product, error) { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.products[product.ID]; !ok { + return nil, ErrProductNotFound + } + product.UpdatedAt = time.Now() + r.products[product.ID] = product + return product, nil +} + +func (r *InMemoryProductRepository) DeleteProduct(ctx context.Context, productID uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.products[productID]; !ok { + return ErrProductNotFound + } + delete(r.products, productID) + return nil +} diff --git a/services/product-service/internal/infrastructure/repository/pg_respository.go b/services/product-service/internal/infrastructure/repository/pg_respository.go index 6d6dd13..1908a97 100644 --- a/services/product-service/internal/infrastructure/repository/pg_respository.go +++ b/services/product-service/internal/infrastructure/repository/pg_respository.go @@ -1,17 +1,115 @@ package repository import ( - "askshop/services/product-service/internal/domain" - "context" + "askshop/services/product-service/internal/domain" + "context" + + "github.com/google/uuid" + "gorm.io/gorm" ) -//ProductRepository defines the data access interface for product operations +// ensure PGProductRepository satisfies ProductRepository at compile time. +var _ ProductRepository = (*PGProductRepository)(nil) + +// PGProductRepository implements ProductRepository backed by Postgres via GORM. +type PGProductRepository struct { + db *gorm.DB +} + +// NewPGProductRepository creates a new Postgres-backed product repository. +func NewPGProductRepository(db *gorm.DB) *PGProductRepository { + return &PGProductRepository{db: db} +} + +func (r *PGProductRepository) preload() *gorm.DB { + return r.db.Preload("Images").Preload("Categories") +} + +func (r *PGProductRepository) GetProductByID(ctx context.Context, productID string) (*domain.Product, error) { + id, err := uuid.Parse(productID) + if err != nil { + return nil, err + } + var product domain.Product + if err := r.preload().WithContext(ctx).First(&product, "id = ?", id).Error; err != nil { + return nil, err + } + return &product, nil +} + +func (r *PGProductRepository) GetProductByCategory(ctx context.Context, category string) ([]*domain.Product, error) { + var products []*domain.Product + err := r.preload().WithContext(ctx). + Joins("JOIN product_categories ON product_categories.product_id = products.id"). + Joins("JOIN categories ON categories.id = product_categories.category_id"). + Where("categories.slug = ?", category). + Find(&products).Error + return products, err +} + +func (r *PGProductRepository) GetAllProducts(ctx context.Context) ([]*domain.Product, error) { + var products []*domain.Product + err := r.preload().WithContext(ctx).Find(&products).Error + return products, err +} + +func (r *PGProductRepository) GetAllProductsPage(ctx context.Context, limit, offset int) ([]*domain.Product, int64, error) { + var products []*domain.Product + var total int64 + if err := r.db.WithContext(ctx).Model(&domain.Product{}).Count(&total).Error; err != nil { + return nil, 0, err + } + err := r.preload().WithContext(ctx).Limit(limit).Offset(offset).Order("created_at DESC").Find(&products).Error + return products, total, err +} + +func (r *PGProductRepository) GetProductByCategoryPage(ctx context.Context, category string, limit, offset int) ([]*domain.Product, int64, error) { + base := r.db.WithContext(ctx).Model(&domain.Product{}). + Joins("JOIN product_categories ON product_categories.product_id = products.id"). + Joins("JOIN categories ON categories.id = product_categories.category_id"). + Where("categories.slug = ?", category) + + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, 0, err + } + + var products []*domain.Product + err := r.preload().WithContext(ctx). + Joins("JOIN product_categories ON product_categories.product_id = products.id"). + Joins("JOIN categories ON categories.id = product_categories.category_id"). + Where("categories.slug = ?", category). + Limit(limit).Offset(offset).Order("products.created_at DESC"). + Find(&products).Error + return products, total, err +} + +func (r *PGProductRepository) SearchProducts(ctx context.Context, query string, limit, offset int) ([]*domain.Product, int64, error) { + var total int64 + if err := r.db.WithContext(ctx).Model(&domain.Product{}).Scopes(domain.ScopeSearch(query)).Count(&total).Error; err != nil { + return nil, 0, err + } + + var products []*domain.Product + err := r.preload().WithContext(ctx).Scopes(domain.ScopeSearch(query)). + Limit(limit).Offset(offset).Order("created_at DESC").Find(&products).Error + return products, total, err +} + +func (r *PGProductRepository) CreateProduct(ctx context.Context, product *domain.Product) (*domain.Product, error) { + if err := r.db.WithContext(ctx).Create(product).Error; err != nil { + return nil, err + } + return product, nil +} + +func (r *PGProductRepository) UpdateProduct(ctx context.Context, product *domain.Product) (*domain.Product, error) { + if err := r.db.WithContext(ctx).Model(&domain.Product{}).Where("id = ?", product.ID).Updates(product).Error; err != nil { + return nil, err + } + return r.GetProductByID(ctx, product.ID.String()) +} -type ProductRepository interface { - GetProductByID(ctx context.Context, productID string) (*domain.Product, error) - GetProductByCategory(ctx context.Context, category string) ([]*domain.Product, error) - GetAllProducts(ctx context.Context) ([]*domain.Product, error) - // Paginated variants return items and total count - GetAllProductsPage(ctx context.Context, limit, offset int) ([]*domain.Product, int64, error) - GetProductByCategoryPage(ctx context.Context, category string, limit, offset int) ([]*domain.Product, int64, error) +func (r *PGProductRepository) DeleteProduct(ctx context.Context, productID uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&domain.Product{}, "id = ?", productID).Error } diff --git a/services/product-service/internal/infrastructure/rest/product_handler.go b/services/product-service/internal/infrastructure/rest/product_handler.go deleted file mode 100644 index d02cfda..0000000 --- a/services/product-service/internal/infrastructure/rest/product_handler.go +++ /dev/null @@ -1 +0,0 @@ -package http diff --git a/services/product-service/internal/seed/product_seed.go b/services/product-service/internal/seed/product_seed.go index 402c63e..bf8d3fc 100644 --- a/services/product-service/internal/seed/product_seed.go +++ b/services/product-service/internal/seed/product_seed.go @@ -123,14 +123,16 @@ func (s *ProductSeeder) SeedProducts() error { }{ { Product: domain.Product{ - ID: uuid.New(), - Name: "iPhone 15 Pro", - Slug: "iphone-15-pro", - SKU: "IPH15PRO-128-TIT", - Description: "The latest iPhone with titanium design and advanced camera system. Features the powerful A17 Pro chip and enhanced battery life.", - Currency: "USD", - Status: "active", - Tags: datatypes.JSONSlice[string]{"new", "featured", "smartphone", "apple"}, + ID: uuid.New(), + Name: "iPhone 15 Pro", + Slug: "iphone-15-pro", + SKU: "IPH15PRO-128-TIT", + Description: "The latest iPhone with titanium design and advanced camera system. Features the powerful A17 Pro chip and enhanced battery life.", + Currency: "USD", + Status: "active", + PriceCents: 99900, + StockQuantity: 25, + Tags: datatypes.JSONSlice[string]{"new", "featured", "smartphone", "apple"}, }, Categories: []domain.Category{electronics, smartphones}, Images: []domain.ProductImage{ @@ -160,14 +162,16 @@ func (s *ProductSeeder) SeedProducts() error { }, { Product: domain.Product{ - ID: uuid.New(), - Name: "MacBook Pro 16-inch", - Slug: "macbook-pro-16-inch", - SKU: "MBP16-M3-512-SG", - Description: "Professional laptop with M3 chip, 16-inch Liquid Retina display, and up to 22 hours of battery life. Perfect for creative professionals.", - Currency: "USD", - Status: "active", - Tags: datatypes.JSONSlice[string]{"laptop", "professional", "apple", "m3"}, + ID: uuid.New(), + Name: "MacBook Pro 16-inch", + Slug: "macbook-pro-16-inch", + SKU: "MBP16-M3-512-SG", + Description: "Professional laptop with M3 chip, 16-inch Liquid Retina display, and up to 22 hours of battery life. Perfect for creative professionals.", + Currency: "USD", + Status: "active", + PriceCents: 249900, + StockQuantity: 10, + Tags: datatypes.JSONSlice[string]{"laptop", "professional", "apple", "m3"}, }, Categories: []domain.Category{electronics, laptops}, Images: []domain.ProductImage{ @@ -197,14 +201,16 @@ func (s *ProductSeeder) SeedProducts() error { }, { Product: domain.Product{ - ID: uuid.New(), - Name: "Sony WH-1000XM5 Headphones", - Slug: "sony-wh-1000xm5-headphones", - SKU: "SONY-WH1000XM5-BLK", - Description: "Industry-leading noise canceling with premium sound quality. 30-hour battery life and quick charge feature.", - Currency: "USD", - Status: "active", - Tags: datatypes.JSONSlice[string]{"headphones", "wireless", "noise-canceling", "sony"}, + ID: uuid.New(), + Name: "Sony WH-1000XM5 Headphones", + Slug: "sony-wh-1000xm5-headphones", + SKU: "SONY-WH1000XM5-BLK", + Description: "Industry-leading noise canceling with premium sound quality. 30-hour battery life and quick charge feature.", + Currency: "USD", + Status: "active", + PriceCents: 39900, + StockQuantity: 40, + Tags: datatypes.JSONSlice[string]{"headphones", "wireless", "noise-canceling", "sony"}, }, Categories: []domain.Category{electronics, headphones}, Images: []domain.ProductImage{ @@ -223,14 +229,16 @@ func (s *ProductSeeder) SeedProducts() error { }, { Product: domain.Product{ - ID: uuid.New(), - Name: "Classic Denim Jacket", - Slug: "classic-denim-jacket", - SKU: "CDJ-MEN-L-BLUE", - Description: "Timeless denim jacket made from premium cotton. Perfect for casual wear and layering. Available in multiple sizes.", - Currency: "USD", - Status: "active", - Tags: datatypes.JSONSlice[string]{"jacket", "denim", "casual", "classic"}, + ID: uuid.New(), + Name: "Classic Denim Jacket", + Slug: "classic-denim-jacket", + SKU: "CDJ-MEN-L-BLUE", + Description: "Timeless denim jacket made from premium cotton. Perfect for casual wear and layering. Available in multiple sizes.", + Currency: "USD", + Status: "active", + PriceCents: 7900, + StockQuantity: 60, + Tags: datatypes.JSONSlice[string]{"jacket", "denim", "casual", "classic"}, }, Categories: []domain.Category{mensClothing}, Images: []domain.ProductImage{ @@ -260,14 +268,16 @@ func (s *ProductSeeder) SeedProducts() error { }, { Product: domain.Product{ - ID: uuid.New(), - Name: "Elegant Summer Dress", - Slug: "elegant-summer-dress", - SKU: "ESD-WOM-M-FLO", - Description: "Beautiful floral summer dress made from breathable fabric. Perfect for warm weather and special occasions.", - Currency: "USD", - Status: "active", - Tags: datatypes.JSONSlice[string]{"dress", "summer", "floral", "elegant"}, + ID: uuid.New(), + Name: "Elegant Summer Dress", + Slug: "elegant-summer-dress", + SKU: "ESD-WOM-M-FLO", + Description: "Beautiful floral summer dress made from breathable fabric. Perfect for warm weather and special occasions.", + Currency: "USD", + Status: "active", + PriceCents: 5900, + StockQuantity: 45, + Tags: datatypes.JSONSlice[string]{"dress", "summer", "floral", "elegant"}, }, Categories: []domain.Category{womensClothing}, Images: []domain.ProductImage{ @@ -286,14 +296,16 @@ func (s *ProductSeeder) SeedProducts() error { }, { Product: domain.Product{ - ID: uuid.New(), - Name: "Wireless Gaming Mouse", - Slug: "wireless-gaming-mouse", - SKU: "WGM-RGB-BLK-PRO", - Description: "High-precision wireless gaming mouse with customizable RGB lighting and programmable buttons. 50-hour battery life.", - Currency: "USD", - Status: "active", - Tags: datatypes.JSONSlice[string]{"gaming", "mouse", "wireless", "rgb"}, + ID: uuid.New(), + Name: "Wireless Gaming Mouse", + Slug: "wireless-gaming-mouse", + SKU: "WGM-RGB-BLK-PRO", + Description: "High-precision wireless gaming mouse with customizable RGB lighting and programmable buttons. 50-hour battery life.", + Currency: "USD", + Status: "active", + PriceCents: 4900, + StockQuantity: 80, + Tags: datatypes.JSONSlice[string]{"gaming", "mouse", "wireless", "rgb"}, }, Categories: []domain.Category{electronics}, Images: []domain.ProductImage{ @@ -313,14 +325,16 @@ func (s *ProductSeeder) SeedProducts() error { // Draft product for testing { Product: domain.Product{ - ID: uuid.New(), - Name: "Upcoming Product", - Slug: "upcoming-product", - SKU: "UP-DRAFT-001", - Description: "This is a draft product that will be released soon. Stay tuned for updates!", - Currency: "USD", - Status: "draft", - Tags: datatypes.JSONSlice[string]{"coming-soon", "draft"}, + ID: uuid.New(), + Name: "Upcoming Product", + Slug: "upcoming-product", + SKU: "UP-DRAFT-001", + Description: "This is a draft product that will be released soon. Stay tuned for updates!", + Currency: "USD", + Status: "draft", + PriceCents: 0, + StockQuantity: 0, + Tags: datatypes.JSONSlice[string]{"coming-soon", "draft"}, }, Categories: []domain.Category{electronics}, Images: []domain.ProductImage{}, diff --git a/services/product-service/internal/service/product_service.go b/services/product-service/internal/service/product_service.go index 6f2a559..3ecc76d 100644 --- a/services/product-service/internal/service/product_service.go +++ b/services/product-service/internal/service/product_service.go @@ -1,58 +1,78 @@ package service import ( - "askshop/services/product-service/internal/domain" - "askshop/services/product-service/internal/infrastructure/repository" - "context" + "askshop/services/product-service/internal/domain" + "askshop/services/product-service/internal/infrastructure/repository" + "context" + + "github.com/google/uuid" ) type ProductServiceImpl struct { - repo repository.ProductRepository - manager *domain.ProductManager + repo repository.ProductRepository + manager *domain.ProductManager } // NewProductService wires repository with business manager and returns the domain service func NewProductService(repo repository.ProductRepository) domain.ProductService { - return &ProductServiceImpl{ - repo: repo, - manager: domain.NewProductManager(), - } + return &ProductServiceImpl{ + repo: repo, + manager: domain.NewProductManager(), + } } func (s *ProductServiceImpl) GetProducts(ctx context.Context) ([]*domain.Product, error) { - return s.repo.GetAllProducts(ctx) + return s.repo.GetAllProducts(ctx) } func (s *ProductServiceImpl) GetProductByID(ctx context.Context, id string) (*domain.Product, error) { - return s.repo.GetProductByID(ctx, id) + return s.repo.GetProductByID(ctx, id) } func (s *ProductServiceImpl) GetProductsByCategory(ctx context.Context, category string) ([]*domain.Product, error) { - return s.repo.GetProductByCategory(ctx, category) + return s.repo.GetProductByCategory(ctx, category) } // Pagination helpers and methods func clampPagination(page, pageSize int) (int, int) { - if page < 1 { - page = 1 - } - if pageSize <= 0 { - pageSize = 20 - } - if pageSize > 100 { - pageSize = 100 - } - return page, pageSize + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize } func (s *ProductServiceImpl) GetProductsPage(ctx context.Context, page, pageSize int) ([]*domain.Product, int64, error) { - page, pageSize = clampPagination(page, pageSize) - offset := (page - 1) * pageSize - return s.repo.GetAllProductsPage(ctx, pageSize, offset) + page, pageSize = clampPagination(page, pageSize) + offset := (page - 1) * pageSize + return s.repo.GetAllProductsPage(ctx, pageSize, offset) } func (s *ProductServiceImpl) GetProductsByCategoryPage(ctx context.Context, category string, page, pageSize int) ([]*domain.Product, int64, error) { - page, pageSize = clampPagination(page, pageSize) - offset := (page - 1) * pageSize - return s.repo.GetProductByCategoryPage(ctx, category, pageSize, offset) + page, pageSize = clampPagination(page, pageSize) + offset := (page - 1) * pageSize + return s.repo.GetProductByCategoryPage(ctx, category, pageSize, offset) +} + +func (s *ProductServiceImpl) SearchProducts(ctx context.Context, query string, page, pageSize int) ([]*domain.Product, int64, error) { + page, pageSize = clampPagination(page, pageSize) + offset := (page - 1) * pageSize + return s.repo.SearchProducts(ctx, query, pageSize, offset) +} + +func (s *ProductServiceImpl) CreateProduct(ctx context.Context, product *domain.Product) (*domain.Product, error) { + return s.repo.CreateProduct(ctx, product) +} + +func (s *ProductServiceImpl) UpdateProduct(ctx context.Context, product *domain.Product) (*domain.Product, error) { + return s.repo.UpdateProduct(ctx, product) +} + +func (s *ProductServiceImpl) DeleteProduct(ctx context.Context, productID uuid.UUID) error { + return s.repo.DeleteProduct(ctx, productID) } diff --git a/services/product-service/internal/service/product_service_test.go b/services/product-service/internal/service/product_service_test.go new file mode 100644 index 0000000..c1428e8 --- /dev/null +++ b/services/product-service/internal/service/product_service_test.go @@ -0,0 +1,31 @@ +package service + +import "testing" + +func TestClampPagination(t *testing.T) { + tests := []struct { + name string + page int + pageSize int + wantPage int + wantPageSize int + }{ + {"defaults are valid", 1, 20, 1, 20}, + {"zero page clamps to 1", 0, 20, 1, 20}, + {"negative page clamps to 1", -5, 20, 1, 20}, + {"zero page size defaults to 20", 1, 0, 1, 20}, + {"negative page size defaults to 20", 1, -10, 1, 20}, + {"oversized page size clamps to 100", 1, 500, 1, 100}, + {"page size exactly at cap is preserved", 1, 100, 1, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPage, gotPageSize := clampPagination(tt.page, tt.pageSize) + if gotPage != tt.wantPage || gotPageSize != tt.wantPageSize { + t.Errorf("clampPagination(%d, %d) = (%d, %d), want (%d, %d)", + tt.page, tt.pageSize, gotPage, gotPageSize, tt.wantPage, tt.wantPageSize) + } + }) + } +} diff --git a/services/user-service/cmd/main.go b/services/user-service/cmd/main.go index 996b680..62683e5 100644 --- a/services/user-service/cmd/main.go +++ b/services/user-service/cmd/main.go @@ -9,6 +9,7 @@ import ( "askshop/shared/contracts" "askshop/shared/db" "askshop/shared/env" + "askshop/shared/health" "askshop/shared/logger" "github.com/gin-gonic/gin" @@ -111,6 +112,8 @@ func main() { } }) + health.Register(router, "user") + // Add a root route router.GET("/", func(c *gin.Context) { c.JSON(200, gin.H{ diff --git a/shared/contracts/routes_test.go b/shared/contracts/routes_test.go new file mode 100644 index 0000000..78be4b2 --- /dev/null +++ b/shared/contracts/routes_test.go @@ -0,0 +1,29 @@ +package contracts + +import "testing" + +func TestJoinPaths(t *testing.T) { + tests := []struct { + name string + parts []string + want string + }{ + {"no parts", nil, ""}, + {"single part", []string{"/api/v1"}, "/api/v1"}, + {"joins without duplicate slash", []string{"/api/v1", "/products"}, "/api/v1/products"}, + {"handles missing leading slash", []string{"/api/v1", "products"}, "/api/v1/products"}, + {"handles trailing slash on left", []string{"/api/v1/", "/products"}, "/api/v1/products"}, + {"handles both slashes present", []string{"/api/v1/", "/products/"}, "/api/v1/products/"}, + {"three parts", []string{"/api/v1", "/products", "/:id"}, "/api/v1/products/:id"}, + {"all empty parts resolve to root", []string{"", ""}, "/"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := JoinPaths(tt.parts...) + if got != tt.want { + t.Errorf("JoinPaths(%v) = %q, want %q", tt.parts, got, tt.want) + } + }) + } +} diff --git a/shared/events/amqp.go b/shared/events/amqp.go new file mode 100644 index 0000000..ddaf489 --- /dev/null +++ b/shared/events/amqp.go @@ -0,0 +1,155 @@ +// Package events provides a thin RabbitMQ publisher/consumer over the topic +// exchange "askshop.events", keyed by the routing keys declared in +// shared/contracts/amqp.go (e.g. "order.event.placed"). +// +// It degrades gracefully: if RABBITMQ_URL is unset or the broker is +// unreachable, Publish and Consume become no-ops (logged once) instead of +// failing service startup. Local dev without RabbitMQ still works; a real +// broker lights the wiring up. +package events + +import ( + "askshop/shared/env" + "context" + "encoding/json" + "log" + "sync" + + amqp "github.com/rabbitmq/amqp091-go" +) + +const exchangeName = "askshop.events" + +// Publisher publishes events to the askshop.events topic exchange. +type Publisher struct { + mu sync.Mutex + conn *amqp.Connection + ch *amqp.Channel +} + +// NewPublisher dials RABBITMQ_URL and declares the shared exchange. If the URL +// is unset or the broker is unreachable, it returns a Publisher that logs and +// no-ops on Publish rather than failing. +func NewPublisher() *Publisher { + url := env.GetString("RABBITMQ_URL", "") + if url == "" { + log.Println("events: RABBITMQ_URL not set, publishing disabled (no-op)") + return &Publisher{} + } + + conn, err := amqp.Dial(url) + if err != nil { + log.Printf("events: failed to connect to RabbitMQ, publishing disabled: %v", err) + return &Publisher{} + } + + ch, err := conn.Channel() + if err != nil { + log.Printf("events: failed to open channel, publishing disabled: %v", err) + conn.Close() + return &Publisher{} + } + + if err := ch.ExchangeDeclare(exchangeName, "topic", true, false, false, false, nil); err != nil { + log.Printf("events: failed to declare exchange, publishing disabled: %v", err) + ch.Close() + conn.Close() + return &Publisher{} + } + + log.Println("events: connected to RabbitMQ") + return &Publisher{conn: conn, ch: ch} +} + +// Publish sends payload as JSON to the given routing key (see shared/contracts/amqp.go). +// It's a no-op (returns nil) if the publisher has no live connection. +func (p *Publisher) Publish(ctx context.Context, routingKey string, payload interface{}) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.ch == nil { + return nil + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + return p.ch.PublishWithContext(ctx, exchangeName, routingKey, false, false, amqp.Publishing{ + ContentType: "application/json", + Body: body, + }) +} + +// Close releases the underlying channel/connection, if any. +func (p *Publisher) Close() { + p.mu.Lock() + defer p.mu.Unlock() + if p.ch != nil { + p.ch.Close() + } + if p.conn != nil { + p.conn.Close() + } +} + +// Consume starts consuming messages matching routingKey on a dedicated queue +// named by queueName, invoking handler for each delivery. It blocks until ctx +// is cancelled. If RABBITMQ_URL is unset or unreachable, it logs and returns +// immediately (no-op). +func Consume(ctx context.Context, queueName, routingKey string, handler func(body []byte)) { + url := env.GetString("RABBITMQ_URL", "") + if url == "" { + log.Println("events: RABBITMQ_URL not set, consuming disabled (no-op)") + return + } + + conn, err := amqp.Dial(url) + if err != nil { + log.Printf("events: failed to connect to RabbitMQ, consuming disabled: %v", err) + return + } + defer conn.Close() + + ch, err := conn.Channel() + if err != nil { + log.Printf("events: failed to open channel, consuming disabled: %v", err) + return + } + defer ch.Close() + + if err := ch.ExchangeDeclare(exchangeName, "topic", true, false, false, false, nil); err != nil { + log.Printf("events: failed to declare exchange, consuming disabled: %v", err) + return + } + + q, err := ch.QueueDeclare(queueName, true, false, false, false, nil) + if err != nil { + log.Printf("events: failed to declare queue %s, consuming disabled: %v", queueName, err) + return + } + + if err := ch.QueueBind(q.Name, routingKey, exchangeName, false, nil); err != nil { + log.Printf("events: failed to bind queue %s to %s, consuming disabled: %v", queueName, routingKey, err) + return + } + + msgs, err := ch.Consume(q.Name, "", true, false, false, false, nil) + if err != nil { + log.Printf("events: failed to start consuming from %s, consuming disabled: %v", queueName, err) + return + } + + log.Printf("events: consuming queue=%s routingKey=%s", queueName, routingKey) + for { + select { + case <-ctx.Done(): + return + case d, ok := <-msgs: + if !ok { + return + } + handler(d.Body) + } + } +} diff --git a/shared/health/health.go b/shared/health/health.go new file mode 100644 index 0000000..8ae71fc --- /dev/null +++ b/shared/health/health.go @@ -0,0 +1,17 @@ +// Package health provides a consistent /healthz endpoint across services, +// for use by container orchestrators (Kubernetes liveness/readiness probes, +// docker-compose healthchecks) and manual checks alike. +package health + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Register adds GET /healthz to router, reporting the given service name. +func Register(router *gin.Engine, serviceName string) { + router.GET("/healthz", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"service": serviceName, "status": "ok"}) + }) +} diff --git a/shared/proto/product.pb.go b/shared/proto/product.pb.go index d3355d0..7d2dfde 100644 --- a/shared/proto/product.pb.go +++ b/shared/proto/product.pb.go @@ -1,8 +1,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.7 -// protoc v4.24.3 -// source: services/product-service/internal/infrastructure/grpc/product_proto/product.proto +// protoc-gen-go v1.36.6 +// protoc v3.21.12 +// source: shared/proto/product.proto package proto @@ -24,17 +24,19 @@ const ( // Product represents a merchandise item in the catalog type Product struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // Product name - Slug string `protobuf:"bytes,3,opt,name=slug,proto3" json:"slug,omitempty"` // URL-friendly identifier - Sku string `protobuf:"bytes,4,opt,name=sku,proto3" json:"sku,omitempty"` // Stock Keeping Unit - Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"` // Detailed description - Currency string `protobuf:"bytes,6,opt,name=currency,proto3" json:"currency,omitempty"` // 3-letter currency code (USD, EUR, etc) - Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` // Product status: draft, active, archived - Tags []string `protobuf:"bytes,8,rep,name=tags,proto3" json:"tags,omitempty"` // Array of string tags - Images []*ProductImage `protobuf:"bytes,9,rep,name=images,proto3" json:"images,omitempty"` // Product images - Categories []*Category `protobuf:"bytes,10,rep,name=categories,proto3" json:"categories,omitempty"` // Associated categories + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // Product name + Slug string `protobuf:"bytes,3,opt,name=slug,proto3" json:"slug,omitempty"` // URL-friendly identifier + Sku string `protobuf:"bytes,4,opt,name=sku,proto3" json:"sku,omitempty"` // Stock Keeping Unit + Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"` // Detailed description + Currency string `protobuf:"bytes,6,opt,name=currency,proto3" json:"currency,omitempty"` // 3-letter currency code (USD, EUR, etc) + Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` // Product status: draft, active, archived + Tags []string `protobuf:"bytes,8,rep,name=tags,proto3" json:"tags,omitempty"` // Array of string tags + Images []*ProductImage `protobuf:"bytes,9,rep,name=images,proto3" json:"images,omitempty"` // Product images + Categories []*Category `protobuf:"bytes,10,rep,name=categories,proto3" json:"categories,omitempty"` // Associated categories + PriceCents int64 `protobuf:"varint,14,opt,name=price_cents,json=priceCents,proto3" json:"price_cents,omitempty"` // Unit price in minor currency units (e.g. cents) + StockQuantity int32 `protobuf:"varint,15,opt,name=stock_quantity,json=stockQuantity,proto3" json:"stock_quantity,omitempty"` // Units available for sale // Timestamps for record-keeping CreatedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` @@ -45,7 +47,7 @@ type Product struct { func (x *Product) Reset() { *x = Product{} - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[0] + mi := &file_shared_proto_product_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -57,7 +59,7 @@ func (x *Product) String() string { func (*Product) ProtoMessage() {} func (x *Product) ProtoReflect() protoreflect.Message { - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[0] + mi := &file_shared_proto_product_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -70,7 +72,7 @@ func (x *Product) ProtoReflect() protoreflect.Message { // Deprecated: Use Product.ProtoReflect.Descriptor instead. func (*Product) Descriptor() ([]byte, []int) { - return file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescGZIP(), []int{0} + return file_shared_proto_product_proto_rawDescGZIP(), []int{0} } func (x *Product) GetId() string { @@ -143,6 +145,20 @@ func (x *Product) GetCategories() []*Category { return nil } +func (x *Product) GetPriceCents() int64 { + if x != nil { + return x.PriceCents + } + return 0 +} + +func (x *Product) GetStockQuantity() int32 { + if x != nil { + return x.StockQuantity + } + return 0 +} + func (x *Product) GetCreatedAt() *timestamppb.Timestamp { if x != nil { return x.CreatedAt @@ -182,7 +198,7 @@ type ProductImage struct { func (x *ProductImage) Reset() { *x = ProductImage{} - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[1] + mi := &file_shared_proto_product_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -194,7 +210,7 @@ func (x *ProductImage) String() string { func (*ProductImage) ProtoMessage() {} func (x *ProductImage) ProtoReflect() protoreflect.Message { - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[1] + mi := &file_shared_proto_product_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -207,7 +223,7 @@ func (x *ProductImage) ProtoReflect() protoreflect.Message { // Deprecated: Use ProductImage.ProtoReflect.Descriptor instead. func (*ProductImage) Descriptor() ([]byte, []int) { - return file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescGZIP(), []int{1} + return file_shared_proto_product_proto_rawDescGZIP(), []int{1} } func (x *ProductImage) GetId() string { @@ -284,7 +300,7 @@ type Category struct { func (x *Category) Reset() { *x = Category{} - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[2] + mi := &file_shared_proto_product_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -296,7 +312,7 @@ func (x *Category) String() string { func (*Category) ProtoMessage() {} func (x *Category) ProtoReflect() protoreflect.Message { - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[2] + mi := &file_shared_proto_product_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -309,7 +325,7 @@ func (x *Category) ProtoReflect() protoreflect.Message { // Deprecated: Use Category.ProtoReflect.Descriptor instead. func (*Category) Descriptor() ([]byte, []int) { - return file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescGZIP(), []int{2} + return file_shared_proto_product_proto_rawDescGZIP(), []int{2} } func (x *Category) GetId() string { @@ -381,7 +397,7 @@ type ProductList struct { func (x *ProductList) Reset() { *x = ProductList{} - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[3] + mi := &file_shared_proto_product_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -393,7 +409,7 @@ func (x *ProductList) String() string { func (*ProductList) ProtoMessage() {} func (x *ProductList) ProtoReflect() protoreflect.Message { - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[3] + mi := &file_shared_proto_product_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -406,7 +422,7 @@ func (x *ProductList) ProtoReflect() protoreflect.Message { // Deprecated: Use ProductList.ProtoReflect.Descriptor instead. func (*ProductList) Descriptor() ([]byte, []int) { - return file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescGZIP(), []int{3} + return file_shared_proto_product_proto_rawDescGZIP(), []int{3} } func (x *ProductList) GetProducts() []*Product { @@ -452,7 +468,7 @@ type ProductRequest struct { func (x *ProductRequest) Reset() { *x = ProductRequest{} - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[4] + mi := &file_shared_proto_product_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -464,7 +480,7 @@ func (x *ProductRequest) String() string { func (*ProductRequest) ProtoMessage() {} func (x *ProductRequest) ProtoReflect() protoreflect.Message { - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[4] + mi := &file_shared_proto_product_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -477,7 +493,7 @@ func (x *ProductRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProductRequest.ProtoReflect.Descriptor instead. func (*ProductRequest) Descriptor() ([]byte, []int) { - return file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescGZIP(), []int{4} + return file_shared_proto_product_proto_rawDescGZIP(), []int{4} } func (x *ProductRequest) GetIdentifier() isProductRequest_Identifier { @@ -554,7 +570,7 @@ type ProductCreateRequest struct { func (x *ProductCreateRequest) Reset() { *x = ProductCreateRequest{} - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[5] + mi := &file_shared_proto_product_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -566,7 +582,7 @@ func (x *ProductCreateRequest) String() string { func (*ProductCreateRequest) ProtoMessage() {} func (x *ProductCreateRequest) ProtoReflect() protoreflect.Message { - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[5] + mi := &file_shared_proto_product_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -579,7 +595,7 @@ func (x *ProductCreateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProductCreateRequest.ProtoReflect.Descriptor instead. func (*ProductCreateRequest) Descriptor() ([]byte, []int) { - return file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescGZIP(), []int{5} + return file_shared_proto_product_proto_rawDescGZIP(), []int{5} } func (x *ProductCreateRequest) GetName() string { @@ -665,7 +681,7 @@ type ProductUpdateRequest struct { func (x *ProductUpdateRequest) Reset() { *x = ProductUpdateRequest{} - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[6] + mi := &file_shared_proto_product_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -677,7 +693,7 @@ func (x *ProductUpdateRequest) String() string { func (*ProductUpdateRequest) ProtoMessage() {} func (x *ProductUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[6] + mi := &file_shared_proto_product_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -690,7 +706,7 @@ func (x *ProductUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProductUpdateRequest.ProtoReflect.Descriptor instead. func (*ProductUpdateRequest) Descriptor() ([]byte, []int) { - return file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescGZIP(), []int{6} + return file_shared_proto_product_proto_rawDescGZIP(), []int{6} } func (x *ProductUpdateRequest) GetId() string { @@ -780,7 +796,7 @@ type ListProductsRequest struct { func (x *ListProductsRequest) Reset() { *x = ListProductsRequest{} - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[7] + mi := &file_shared_proto_product_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -792,7 +808,7 @@ func (x *ListProductsRequest) String() string { func (*ListProductsRequest) ProtoMessage() {} func (x *ListProductsRequest) ProtoReflect() protoreflect.Message { - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[7] + mi := &file_shared_proto_product_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -805,7 +821,7 @@ func (x *ListProductsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProductsRequest.ProtoReflect.Descriptor instead. func (*ListProductsRequest) Descriptor() ([]byte, []int) { - return file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescGZIP(), []int{7} + return file_shared_proto_product_proto_rawDescGZIP(), []int{7} } func (x *ListProductsRequest) GetPage() int32 { @@ -875,7 +891,7 @@ type DeleteResponse struct { func (x *DeleteResponse) Reset() { *x = DeleteResponse{} - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[8] + mi := &file_shared_proto_product_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -887,7 +903,7 @@ func (x *DeleteResponse) String() string { func (*DeleteResponse) ProtoMessage() {} func (x *DeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[8] + mi := &file_shared_proto_product_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -900,7 +916,7 @@ func (x *DeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteResponse.ProtoReflect.Descriptor instead. func (*DeleteResponse) Descriptor() ([]byte, []int) { - return file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescGZIP(), []int{8} + return file_shared_proto_product_proto_rawDescGZIP(), []int{8} } func (x *DeleteResponse) GetSuccess() bool { @@ -917,11 +933,11 @@ func (x *DeleteResponse) GetMessage() string { return "" } -var File_services_product_service_internal_infrastructure_grpc_product_proto_product_proto protoreflect.FileDescriptor +var File_shared_proto_product_proto protoreflect.FileDescriptor -const file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDesc = "" + +const file_shared_proto_product_proto_rawDesc = "" + "\n" + - "Qservices/product-service/internal/infrastructure/grpc/product_proto/product.proto\x12\aproduct\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd0\x03\n" + + "\x1ashared/proto/product.proto\x12\aproduct\x1a\x1fgoogle/protobuf/timestamp.proto\"\x98\x04\n" + "\aProduct\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" + @@ -935,7 +951,10 @@ const file_services_product_service_internal_infrastructure_grpc_product_proto_p "\n" + "categories\x18\n" + " \x03(\v2\x11.product.CategoryR\n" + - "categories\x129\n" + + "categories\x12\x1f\n" + + "\vprice_cents\x18\x0e \x01(\x03R\n" + + "priceCents\x12%\n" + + "\x0estock_quantity\x18\x0f \x01(\x05R\rstockQuantity\x129\n" + "\n" + "created_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + "\n" + @@ -1023,19 +1042,19 @@ const file_services_product_service_internal_infrastructure_grpc_product_proto_p "\rDeleteProduct\x12\x17.product.ProductRequest\x1a\x17.product.DeleteResponseBKZIservices/product-service/internal/infrastructure/grpc/product_proto;protob\x06proto3" var ( - file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescOnce sync.Once - file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescData []byte + file_shared_proto_product_proto_rawDescOnce sync.Once + file_shared_proto_product_proto_rawDescData []byte ) -func file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescGZIP() []byte { - file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescOnce.Do(func() { - file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDesc), len(file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDesc))) +func file_shared_proto_product_proto_rawDescGZIP() []byte { + file_shared_proto_product_proto_rawDescOnce.Do(func() { + file_shared_proto_product_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_shared_proto_product_proto_rawDesc), len(file_shared_proto_product_proto_rawDesc))) }) - return file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDescData + return file_shared_proto_product_proto_rawDescData } -var file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes = make([]protoimpl.MessageInfo, 9) -var file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_goTypes = []any{ +var file_shared_proto_product_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_shared_proto_product_proto_goTypes = []any{ (*Product)(nil), // 0: product.Product (*ProductImage)(nil), // 1: product.ProductImage (*Category)(nil), // 2: product.Category @@ -1047,7 +1066,7 @@ var file_services_product_service_internal_infrastructure_grpc_product_proto_pro (*DeleteResponse)(nil), // 8: product.DeleteResponse (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp } -var file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_depIdxs = []int32{ +var file_shared_proto_product_proto_depIdxs = []int32{ 1, // 0: product.Product.images:type_name -> product.ProductImage 2, // 1: product.Product.categories:type_name -> product.Category 9, // 2: product.Product.created_at:type_name -> google.protobuf.Timestamp @@ -1077,14 +1096,12 @@ var file_services_product_service_internal_infrastructure_grpc_product_proto_pro 0, // [0:12] is the sub-list for field type_name } -func init() { - file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_init() -} -func file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_init() { - if File_services_product_service_internal_infrastructure_grpc_product_proto_product_proto != nil { +func init() { file_shared_proto_product_proto_init() } +func file_shared_proto_product_proto_init() { + if File_shared_proto_product_proto != nil { return } - file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes[4].OneofWrappers = []any{ + file_shared_proto_product_proto_msgTypes[4].OneofWrappers = []any{ (*ProductRequest_Id)(nil), (*ProductRequest_Slug)(nil), (*ProductRequest_Sku)(nil), @@ -1093,17 +1110,17 @@ func file_services_product_service_internal_infrastructure_grpc_product_proto_pr out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDesc), len(file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_shared_proto_product_proto_rawDesc), len(file_shared_proto_product_proto_rawDesc)), NumEnums: 0, NumMessages: 9, NumExtensions: 0, NumServices: 1, }, - GoTypes: file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_goTypes, - DependencyIndexes: file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_depIdxs, - MessageInfos: file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_msgTypes, + GoTypes: file_shared_proto_product_proto_goTypes, + DependencyIndexes: file_shared_proto_product_proto_depIdxs, + MessageInfos: file_shared_proto_product_proto_msgTypes, }.Build() - File_services_product_service_internal_infrastructure_grpc_product_proto_product_proto = out.File - file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_goTypes = nil - file_services_product_service_internal_infrastructure_grpc_product_proto_product_proto_depIdxs = nil + File_shared_proto_product_proto = out.File + file_shared_proto_product_proto_goTypes = nil + file_shared_proto_product_proto_depIdxs = nil } diff --git a/shared/proto/product.proto b/shared/proto/product.proto index 02053f3..0664d7e 100644 --- a/shared/proto/product.proto +++ b/shared/proto/product.proto @@ -18,7 +18,9 @@ message Product { repeated string tags = 8; // Array of string tags repeated ProductImage images = 9; // Product images repeated Category categories = 10; // Associated categories - + int64 price_cents = 14; // Unit price in minor currency units (e.g. cents) + int32 stock_quantity = 15; // Units available for sale + // Timestamps for record-keeping google.protobuf.Timestamp created_at = 11; google.protobuf.Timestamp updated_at = 12; diff --git a/shared/proto/product_grpc.pb.go b/shared/proto/product_grpc.pb.go index 1e530e6..ab23f04 100644 --- a/shared/proto/product_grpc.pb.go +++ b/shared/proto/product_grpc.pb.go @@ -1,8 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 -// - protoc v4.24.3 -// source: services/product-service/internal/infrastructure/grpc/product_proto/product.proto +// - protoc-gen-go-grpc v1.6.2 +// - protoc v3.21.12 +// source: shared/proto/product.proto package proto @@ -121,19 +121,19 @@ type ProductServiceServer interface { type UnimplementedProductServiceServer struct{} func (UnimplementedProductServiceServer) GetProduct(context.Context, *ProductRequest) (*Product, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetProduct not implemented") + return nil, status.Error(codes.Unimplemented, "method GetProduct not implemented") } func (UnimplementedProductServiceServer) ListProducts(context.Context, *ListProductsRequest) (*ProductList, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListProducts not implemented") + return nil, status.Error(codes.Unimplemented, "method ListProducts not implemented") } func (UnimplementedProductServiceServer) CreateProduct(context.Context, *ProductCreateRequest) (*Product, error) { - return nil, status.Errorf(codes.Unimplemented, "method CreateProduct not implemented") + return nil, status.Error(codes.Unimplemented, "method CreateProduct not implemented") } func (UnimplementedProductServiceServer) UpdateProduct(context.Context, *ProductUpdateRequest) (*Product, error) { - return nil, status.Errorf(codes.Unimplemented, "method UpdateProduct not implemented") + return nil, status.Error(codes.Unimplemented, "method UpdateProduct not implemented") } func (UnimplementedProductServiceServer) DeleteProduct(context.Context, *ProductRequest) (*DeleteResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeleteProduct not implemented") + return nil, status.Error(codes.Unimplemented, "method DeleteProduct not implemented") } func (UnimplementedProductServiceServer) mustEmbedUnimplementedProductServiceServer() {} func (UnimplementedProductServiceServer) testEmbeddedByValue() {} @@ -146,7 +146,7 @@ type UnsafeProductServiceServer interface { } func RegisterProductServiceServer(s grpc.ServiceRegistrar, srv ProductServiceServer) { - // If the following call pancis, it indicates UnimplementedProductServiceServer was + // If the following call panics, it indicates UnimplementedProductServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -275,5 +275,5 @@ var ProductService_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{}, - Metadata: "services/product-service/internal/infrastructure/grpc/product_proto/product.proto", + Metadata: "shared/proto/product.proto", } diff --git a/shared/util/util.go b/shared/util/util.go index e19e69b..81102d7 100644 --- a/shared/util/util.go +++ b/shared/util/util.go @@ -8,5 +8,5 @@ import ( // GetRandomAvatar returns a random avatar URL from the randomuser.me API func GetRandomAvatar(index uuid.UUID) string { - return fmt.Sprintf("https://randomuser.me/api/portraits/lego/%d.jpg", index.String()) + return fmt.Sprintf("https://randomuser.me/api/portraits/lego/%s.jpg", index.String()) } diff --git a/shared/util/util_test.go b/shared/util/util_test.go new file mode 100644 index 0000000..ed371db --- /dev/null +++ b/shared/util/util_test.go @@ -0,0 +1,32 @@ +package util + +import ( + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestGetRandomAvatar(t *testing.T) { + id := uuid.New() + got := GetRandomAvatar(id) + + want := "https://randomuser.me/api/portraits/lego/" + id.String() + ".jpg" + if got != want { + t.Errorf("GetRandomAvatar(%s) = %q, want %q", id, got, want) + } + if !strings.HasPrefix(got, "https://randomuser.me/api/portraits/lego/") { + t.Errorf("GetRandomAvatar(%s) = %q, missing expected prefix", id, got) + } + if !strings.HasSuffix(got, ".jpg") { + t.Errorf("GetRandomAvatar(%s) = %q, missing .jpg suffix", id, got) + } +} + +func TestGetRandomAvatarNilUUID(t *testing.T) { + got := GetRandomAvatar(uuid.Nil) + want := "https://randomuser.me/api/portraits/lego/00000000-0000-0000-0000-000000000000.jpg" + if got != want { + t.Errorf("GetRandomAvatar(uuid.Nil) = %q, want %q", got, want) + } +} diff --git a/tools/create_service.go b/tools/create_service.go index 1d75277..2c99ebc 100644 --- a/tools/create_service.go +++ b/tools/create_service.go @@ -46,7 +46,7 @@ This service handles all %s-related operations in the system. The service follows Clean Architecture principles with the following structure: -` + "```" + ` +`+"```"+` services/%s-service/ ├── cmd/ # Application entry points │ └── main.go # Main application setup @@ -61,26 +61,26 @@ services/%s-service/ ├── pkg/ # Public packages │ └── types/ # Shared types and models └── README.md # This file -` + "```" + ` +`+"```"+` ### Layer Responsibilities -1. **Domain Layer** (` + "`internal/domain/`" + `) +1. **Domain Layer** (`+"`internal/domain/`"+`) - Contains business domain interfaces - Defines contracts for repositories and services - Pure business logic, no implementation details -2. **Service Layer** (` + "`internal/service/`" + `) +2. **Service Layer** (`+"`internal/service/`"+`) - Implements business logic - Uses repository interfaces - Coordinates between different parts of the system -3. **Infrastructure Layer** (` + "`internal/infrastructure/`" + `) - - ` + "`repository/`" + `: Implements data persistence - - ` + "`events/`" + `: Handles event publishing and consuming - - ` + "`grpc/`" + `: Handles gRPC communication +3. **Infrastructure Layer** (`+"`internal/infrastructure/`"+`) + - `+"`repository/`"+`: Implements data persistence + - `+"`events/`"+`: Handles event publishing and consuming + - `+"`grpc/`"+`: Handles gRPC communication -4. **Public Types** (` + "`pkg/types/`" + `) +4. **Public Types** (`+"`pkg/types/`"+`) - Contains shared types and models - Can be imported by other services @@ -116,4 +116,4 @@ services/%s-service/ │ └── types/ # Shared types and models └── README.md # This file `, *serviceName, *serviceName) -} \ No newline at end of file +}