From 48b010aca9065e399b8fa023857fa5f6890b3882 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Sat, 30 May 2026 13:01:01 +0700 Subject: [PATCH 1/3] feat: add GCS multi-image support for products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single image_url field with a product_images table supporting multiple ordered images per product. Position 0 is the primary display image; image_url in ProductResponse is computed from it for backward compatibility. New endpoints (admin only, enforced by gateway): POST /api/products/:id/images — upload file to GCS, create DB record DELETE /api/products/:id/images/:id — remove from GCS and DB PUT /api/products/:id/images/reorder — reorder by position (0 = primary) StorageService interface with NoopStorage fallback means the service starts cleanly when GCS_BUCKET_NAME is unset — upload returns 503, all other product endpoints are unaffected. DeleteProduct now cleans up all GCS objects before removing the DB row. ProductImage table is auto-migrated alongside existing tables. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 12 + docker-compose.yml | 2 + docs/GCS_IMAGE_UPLOAD_PLAN.md | 856 ++++++++++++++++++ services/product-service/cmd/config.go | 20 +- .../product-service/cmd/infrastructure.go | 37 +- services/product-service/cmd/run.go | 10 +- services/product-service/go.mod | 64 +- services/product-service/go.sum | 146 ++- .../product-service/internal/domain/errors.go | 7 +- .../internal/domain/product.go | 98 +- .../internal/domain/repository.go | 17 +- .../internal/domain/service.go | 9 +- .../internal/domain/storage.go | 27 + .../internal/handler/product_handler.go | 135 ++- .../internal/repository/product_repository.go | 89 +- .../internal/route/product_route.go | 5 + .../internal/service/product_service.go | 113 ++- .../product-service/internal/storage/gcs.go | 71 ++ 18 files changed, 1595 insertions(+), 123 deletions(-) create mode 100644 docs/GCS_IMAGE_UPLOAD_PLAN.md create mode 100644 services/product-service/internal/domain/storage.go create mode 100644 services/product-service/internal/storage/gcs.go diff --git a/.env.example b/.env.example index 0132116..0a62e16 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,18 @@ TWILIO_ACCOUNT_SID=AC_your_account_sid TWILIO_AUTH_TOKEN=your_auth_token TWILIO_FROM_NUMBER=+1234567890 +# ============================================================ +# GCS IMAGE STORAGE (product-service) +# ============================================================ +# Google Cloud Storage bucket for product images. +# Leave empty to disable image upload (service still starts without it). +GCS_BUCKET_NAME=auron-product-images + +# Raw JSON content of the service account key file. +# To prepare: cat gcs-credentials.json +# Leave empty to use Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS). +GCS_CREDENTIALS_JSON= + # ============================================================ # FRONTEND # ============================================================ diff --git a/docker-compose.yml b/docker-compose.yml index 579d24d..031e372 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,8 @@ services: - DATABASE_URL=postgres://auron:auron_pass@products-db:5432/products_db?sslmode=disable - REDIS_URL=redis://redis:6379/0 - KAFKA_BROKERS=kafka:29092 + - GCS_BUCKET_NAME=${GCS_BUCKET_NAME} + - GCS_CREDENTIALS_JSON=${GCS_CREDENTIALS_JSON} depends_on: products-db: condition: service_healthy diff --git a/docs/GCS_IMAGE_UPLOAD_PLAN.md b/docs/GCS_IMAGE_UPLOAD_PLAN.md new file mode 100644 index 0000000..2a1a264 --- /dev/null +++ b/docs/GCS_IMAGE_UPLOAD_PLAN.md @@ -0,0 +1,856 @@ +# GCS Product Image Upload — Implementation Plan + +> **Scope:** Add Google Cloud Storage image support to the `product-service`, with support for **multiple images per product**. +> **Approach:** Images are managed as a separate resource attached to a product. Admins upload images after creating a product via dedicated endpoints. Images are ordered by `position`; position 0 is the primary (display) image. + +--- + +## What changes + +| Area | Change | +|---|---| +| `product-service` | New `product_images` table (id, product_id, url, position) | +| `product-service` | Remove `image_url` from `ProductRequest` and `Product` entity | +| `product-service` | `ProductResponse` gains `images []ProductImage`; `image_url` kept as computed convenience field | +| `product-service` | New `StorageService` interface + GCS implementation | +| `product-service` | 3 new endpoints: upload, delete, reorder | +| `product-service` | `DeleteProduct` cleans up all GCS objects for the product | +| `docker-compose.yml` | Inject `GCS_BUCKET_NAME`, `GCS_CREDENTIALS_JSON` into product-service | +| `.env.example` | Document the two new variables | +| `docs/API_DOCS.md` | Document new endpoints, updated response shape | + +--- + +## 1. GCP Setup (one-time, manual) + +### 1a. Create the bucket + +```bash +gcloud storage buckets create gs://auron-product-images \ + --project=YOUR_PROJECT_ID \ + --location=ASIA-SOUTHEAST1 \ + --uniform-bucket-level-access +``` + +### 1b. Make bucket publicly readable + +Product images must be publicly accessible via URL. + +```bash +gcloud storage buckets add-iam-policy-binding gs://auron-product-images \ + --member=allUsers \ + --role=roles/storage.objectViewer +``` + +### 1c. Create a service account + +```bash +gcloud iam service-accounts create auron-product-service \ + --display-name="Auron Product Service" \ + --project=YOUR_PROJECT_ID +``` + +### 1d. Grant the service account Object Admin on the bucket + +Needs both create (upload) and delete (cleanup on update/delete). + +```bash +gcloud storage buckets add-iam-policy-binding gs://auron-product-images \ + --member=serviceAccount:auron-product-service@YOUR_PROJECT_ID.iam.gserviceaccount.com \ + --role=roles/storage.objectAdmin +``` + +### 1e. Generate the key JSON + +```bash +gcloud iam service-accounts keys create gcs-credentials.json \ + --iam-account=auron-product-service@YOUR_PROJECT_ID.iam.gserviceaccount.com +``` + +Never commit this file. See §5 for how to pass it to Docker. + +--- + +## 2. Database — new table + +No changes to the existing `products` table. A new `product_images` table is added. + +```sql +-- Applied automatically via GORM AutoMigrate +CREATE TABLE product_images ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + url TEXT NOT NULL, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_product_images_product_id ON product_images(product_id); +``` + +`ON DELETE CASCADE` ensures images are removed from the DB if a product is hard-deleted via SQL. The service layer also deletes the GCS objects explicitly before the DB row is removed. + +### Migration note — `image_url` column + +The `image_url` column on the `products` table already exists but will no longer be written to. GORM AutoMigrate never drops columns, so it stays harmlessly in the DB. To clean it up manually after migration: + +```sql +ALTER TABLE products DROP COLUMN image_url; +``` + +This is optional and can be done at any time after deploying the new code, since nothing reads or writes to it anymore. + +--- + +## 3. New API Endpoints + +All three require `Authorization: Bearer ` (enforced by the gateway admin middleware). + +### 3a. Upload image + +``` +POST /api/products/:id/images +Content-Type: multipart/form-data + +Field: image (file) +``` + +Uploads the file to GCS and creates a `product_images` row. The new image is appended at the end of the existing images (highest position + 1). + +**Response `201`:** +```json +{ + "success": true, + "data": { + "id": "a3f7c1d2-...", + "product_id": "b9e2a4f1-...", + "url": "https://storage.googleapis.com/auron-product-images/products/a3f7c1d2-....jpg", + "position": 2, + "created_at": "2026-05-30T10:00:00Z" + } +} +``` + +**Constraints:** +- MIME type: `image/jpeg`, `image/png`, `image/webp` only +- Max size: 5 MB +- Product must exist; returns `404` if not + +### 3b. Delete image + +``` +DELETE /api/products/:id/images/:image_id +``` + +Deletes the GCS object and removes the `product_images` row. After deletion, the remaining images are NOT automatically re-sequenced — call reorder if needed. + +**Response `200`:** +```json +{ "success": true, "message": "image deleted" } +``` + +Returns `404` if the image_id does not belong to the given product. + +### 3c. Reorder images + +``` +PUT /api/products/:id/images/reorder +Content-Type: application/json + +{ + "image_ids": ["uuid-1", "uuid-2", "uuid-3"] +} +``` + +Assigns `position = 0, 1, 2, ...` to the image IDs in the order provided. The image at position 0 is the primary (display) image shown on product cards. All provided IDs must belong to the product. + +**Response `200`:** +```json +{ + "success": true, + "data": [ + { "id": "uuid-1", "url": "...", "position": 0, "created_at": "..." }, + { "id": "uuid-2", "url": "...", "position": 1, "created_at": "..." }, + { "id": "uuid-3", "url": "...", "position": 2, "created_at": "..." } + ] +} +``` + +--- + +## 4. Updated Response Shape + +`ProductResponse` gains an `images` field. The existing `image_url` field is kept as a computed convenience value (the URL of the first image by position) so that the frontend can use it for product cards without iterating the images array. + +```json +{ + "id": "...", + "category_id": "...", + "name": "Product Name", + "description": "...", + "price": 99.99, + "image_url": "https://storage.googleapis.com/auron-product-images/products/primary.jpg", + "images": [ + { "id": "...", "product_id": "...", "url": "https://...", "position": 0, "created_at": "..." }, + { "id": "...", "product_id": "...", "url": "https://...", "position": 1, "created_at": "..." } + ], + "is_active": true, + "created_at": "...", + "updated_at": "..." +} +``` + +`image_url` is `""` (empty string) when a product has no images. + +### `ProductRequest` change (breaking) + +`image_url` is removed from the create and update request body. Images are now managed exclusively via the `/api/products/:id/images` endpoints. + +**Before:** +```json +{ "category_id": "...", "name": "...", "description": "...", "price": 99.99, "image_url": "https://...", "is_active": true } +``` + +**After:** +```json +{ "category_id": "...", "name": "...", "description": "...", "price": 99.99, "is_active": true } +``` + +--- + +## 5. Admin Workflow (Frontend — Phase 7) + +### Create product with images + +``` +1. Fill name, description, price, category → POST /api/products → get product_id +2. Upload images one at a time → POST /api/products/:id/images (repeat per file) +3. Reorder if needed → PUT /api/products/:id/images/reorder +``` + +### Edit product images + +``` +Existing images shown → admin can: + ├─► Upload more → POST /api/products/:id/images + ├─► Delete one → DELETE /api/products/:id/images/:image_id + └─► Drag to reorder → PUT /api/products/:id/images/reorder +``` + +### Primary image + +The image at `position = 0` is the primary image. It shows on product cards and as the hero image on the product detail page. Dragging an image to the first slot in the admin form and saving the new order sets it as primary. + +--- + +## 6. Backend Code Changes + +### 6a. Add dependency + +```bash +cd services/product-service +go get cloud.google.com/go/storage +go get google.golang.org/api/option +go mod tidy +``` + +### 6b. `internal/domain/storage.go` — new file + +```go +package domain + +import ( + "context" + "io" +) + +type StorageService interface { + UploadImage(ctx context.Context, objectName string, r io.Reader, contentType string) (publicURL string, err error) + DeleteImage(ctx context.Context, objectName string) error +} + +// NoopStorage is used when GCS env vars are not set. +// UploadImage returns ErrStorageNotConfigured; DeleteImage is silently ignored. +type NoopStorage struct{} + +func (NoopStorage) UploadImage(_ context.Context, _ string, _ io.Reader, _ string) (string, error) { + return "", ErrStorageNotConfigured +} + +func (NoopStorage) DeleteImage(_ context.Context, _ string) error { + return nil +} +``` + +### 6c. `internal/domain/errors.go` — add new errors + +```go +ErrStorageNotConfigured = errors.New("image storage is not configured") +ErrImageNotFound = errors.New("image not found") +ErrImageLimitExceeded = errors.New("product already has the maximum number of images") +``` + +### 6d. `internal/domain/product.go` — changes + +**Remove** `ImageURL` field from the `Product` struct (GORM stops writing to the column). + +**Add** `ProductImage` entity: +```go +type ProductImage struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;not null;index"` + URL string `json:"url" gorm:"type:text;not null"` + Position int `json:"position" gorm:"not null;default:0"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` +} + +func (ProductImage) TableName() string { return "product_images" } +``` + +**Update** `Product` to include the association: +```go +type Product struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + CategoryID uuid.UUID `json:"category_id" gorm:"type:uuid;not null;index"` + Name string `json:"name" gorm:"type:varchar(500);not null"` + Description string `json:"description" gorm:"type:text"` + Price float64 `json:"price" gorm:"type:numeric(12,2);not null;index"` + Images []ProductImage `json:"images,omitempty" gorm:"foreignKey:ProductID;references:ID;constraint:OnDelete:CASCADE"` + SearchVector string `json:"-" gorm:"-"` + IsActive bool `json:"is_active" gorm:"not null;default:true;index"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` + Category *Category `json:"category,omitempty" gorm:"foreignKey:CategoryID;references:ID"` +} +``` + +**Update** `ProductRequest` — remove `ImageURL`: +```go +type ProductRequest struct { + CategoryID uuid.UUID `json:"category_id" binding:"required"` + Name string `json:"name" binding:"required,max=500"` + Description string `json:"description" binding:"required"` + Price float64 `json:"price" binding:"required,gt=0"` + IsActive *bool `json:"is_active"` +} +``` + +**Update** `ProductResponse` — add `Images`, computed `ImageURL`: +```go +type ProductResponse struct { + ID uuid.UUID `json:"id"` + CategoryID uuid.UUID `json:"category_id"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + ImageURL string `json:"image_url"` // computed: images[0].URL or "" + Images []ProductImage `json:"images"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Category *Category `json:"category,omitempty"` +} +``` + +**Update** `ToResponse()`: +```go +func (p *Product) ToResponse() *ProductResponse { + resp := &ProductResponse{ + ID: p.ID, + CategoryID: p.CategoryID, + Name: p.Name, + Description: p.Description, + Price: p.Price, + Images: p.Images, + IsActive: p.IsActive, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + Category: p.Category, + } + if resp.Images == nil { + resp.Images = []ProductImage{} // always return array, never null + } + if len(p.Images) > 0 { + resp.ImageURL = p.Images[0].URL // position 0 = primary + } + return resp +} +``` + +**Add** `AddImageRequest` and `ReorderRequest` DTOs: +```go +type AddImageRequest struct { + URL string `json:"url"` + Position int `json:"position"` +} + +type ReorderImagesRequest struct { + ImageIDs []uuid.UUID `json:"image_ids" binding:"required,min=1"` +} +``` + +### 6e. `internal/domain/service.go` — add image methods + +```go +type ProductService interface { + // ... existing product and category methods ... + + // Image operations + AddProductImage(ctx context.Context, productID uuid.UUID, url string) (*ProductImage, error) + DeleteProductImage(ctx context.Context, productID, imageID uuid.UUID) (*ProductImage, error) + ReorderProductImages(ctx context.Context, productID uuid.UUID, imageIDs []uuid.UUID) ([]ProductImage, error) + GetProductImages(ctx context.Context, productID uuid.UUID) ([]ProductImage, error) +} +``` + +`DeleteProductImage` returns the deleted image so the caller (handler) can delete the GCS object. + +### 6f. `internal/domain/repository.go` — add image methods + +```go +type ProductRepository interface { + // ... existing methods ... + + AddProductImage(ctx context.Context, image *ProductImage) error + GetProductImage(ctx context.Context, productID, imageID uuid.UUID) (*ProductImage, error) + GetProductImages(ctx context.Context, productID uuid.UUID) ([]ProductImage, error) + DeleteProductImage(ctx context.Context, productID, imageID uuid.UUID) error + UpdateProductImagePositions(ctx context.Context, images []ProductImage) error + GetAllProductImages(ctx context.Context, productID uuid.UUID) ([]ProductImage, error) +} +``` + +### 6g. `internal/storage/gcs.go` — new file + +```go +package storage + +import ( + "context" + "fmt" + "io" + + "cloud.google.com/go/storage" + "google.golang.org/api/option" +) + +type GCSStorage struct { + client *storage.Client + bucketName string +} + +func NewGCSStorage(ctx context.Context, bucketName, credJSON string) (*GCSStorage, error) { + var opts []option.ClientOption + if credJSON != "" { + opts = append(opts, option.WithCredentialsJSON([]byte(credJSON))) + } + // Falls back to GOOGLE_APPLICATION_CREDENTIALS or metadata server if credJSON is empty. + client, err := storage.NewClient(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("gcs: failed to create client: %w", err) + } + return &GCSStorage{client: client, bucketName: bucketName}, nil +} + +func (g *GCSStorage) UploadImage(ctx context.Context, objectName string, r io.Reader, contentType string) (string, error) { + obj := g.client.Bucket(g.bucketName).Object(objectName) + w := obj.NewWriter(ctx) + w.ContentType = contentType + w.CacheControl = "public, max-age=31536000" // 1 year — UUID-named, never stale + + if _, err := io.Copy(w, r); err != nil { + _ = w.Close() + return "", fmt.Errorf("gcs: upload failed: %w", err) + } + if err := w.Close(); err != nil { + return "", fmt.Errorf("gcs: finalise failed: %w", err) + } + return fmt.Sprintf("https://storage.googleapis.com/%s/%s", g.bucketName, objectName), nil +} + +func (g *GCSStorage) DeleteImage(ctx context.Context, objectName string) error { + err := g.client.Bucket(g.bucketName).Object(objectName).Delete(ctx) + if err == storage.ErrObjectNotExist { + return nil // already gone — treat as success + } + return err +} + +func (g *GCSStorage) BucketName() string { return g.bucketName } +``` + +### 6h. `internal/handler/product_handler.go` — new image handlers + +Update struct to include storage: +```go +type ProductHandler struct { + service domain.ProductService + storage domain.StorageService + bucket string // needed to extract object name for GCS delete +} + +func NewProductHandler(service domain.ProductService, storage domain.StorageService, bucket string) *ProductHandler { + return &ProductHandler{service: service, storage: storage, bucket: bucket} +} +``` + +**UploadProductImage:** +```go +var ( + allowedMIME = map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} + maxImageBytes = int64(5 << 20) // 5 MB +) + +func (h *ProductHandler) UploadProductImage(c *gin.Context) { + productID, err := parseUUID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxImageBytes) + file, header, err := c.Request.FormFile("image") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "image field is required"}) + return + } + defer file.Close() + + if header.Size > maxImageBytes { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "image must be 5 MB or smaller"}) + return + } + + buf := make([]byte, 512) + n, _ := file.Read(buf) + contentType := http.DetectContentType(buf[:n]) + ext, ok := allowedMIME[contentType] + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "only JPEG, PNG, and WebP are accepted"}) + return + } + if seeker, ok := file.(io.Seeker); ok { + seeker.Seek(0, io.SeekStart) + } + + objectName := fmt.Sprintf("products/%s%s", uuid.New().String(), ext) + url, err := h.storage.UploadImage(c.Request.Context(), objectName, file, contentType) + if err != nil { + if errors.Is(err, domain.ErrStorageNotConfigured) { + c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "error": "image storage is not configured"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to upload image"}) + return + } + + image, err := h.service.AddProductImage(c.Request.Context(), productID, url) + if err != nil { + // GCS upload succeeded but DB insert failed — clean up the orphan object + _ = h.storage.DeleteImage(c.Request.Context(), objectName) + h.handleError(c, err) + return + } + + c.JSON(http.StatusCreated, gin.H{"success": true, "data": image}) +} +``` + +**DeleteProductImage:** +```go +func (h *ProductHandler) DeleteProductImage(c *gin.Context) { + productID, err := parseUUID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + imageID, err := parseUUID(c.Param("image_id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid image id"}) + return + } + + // Service deletes the DB row and returns the deleted image (for GCS cleanup) + deleted, err := h.service.DeleteProductImage(c.Request.Context(), productID, imageID) + if err != nil { + h.handleError(c, err) + return + } + + // Delete the GCS object — best-effort, don't fail the request if GCS is slow + if objectName, ok := extractGCSObjectName(deleted.URL, h.bucket); ok { + _ = h.storage.DeleteImage(c.Request.Context(), objectName) + } + + c.JSON(http.StatusOK, gin.H{"success": true, "message": "image deleted"}) +} +``` + +**ReorderProductImages:** +```go +func (h *ProductHandler) ReorderProductImages(c *gin.Context) { + productID, err := parseUUID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + + var req domain.ReorderImagesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + images, err := h.service.ReorderProductImages(c.Request.Context(), productID, req.ImageIDs) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": images}) +} +``` + +**Shared helper:** +```go +// extractGCSObjectName extracts the GCS object path from a full public URL. +// Returns ("", false) for non-GCS or non-bucket URLs. +func extractGCSObjectName(imageURL, bucketName string) (string, bool) { + prefix := fmt.Sprintf("https://storage.googleapis.com/%s/", bucketName) + if strings.HasPrefix(imageURL, prefix) { + return strings.TrimPrefix(imageURL, prefix), true + } + return "", false +} +``` + +**handleError** — add new cases: +```go +case errors.Is(err, domain.ErrImageNotFound): + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) +case errors.Is(err, domain.ErrStorageNotConfigured): + c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "error": err.Error()}) +``` + +### 6i. `internal/route/product_route.go` — register new routes + +```go +func RegisterProductRoutes(router *gin.Engine, h *handler.ProductHandler) { + api := router.Group("/") + + api.GET("/products", h.GetProducts) + api.GET("/products/:id", h.GetProductByID) + api.POST("/products", h.CreateProduct) + api.PUT("/products/:id", h.UpdateProduct) + api.DELETE("/products/:id", h.DeleteProduct) + + // Image management + api.POST("/products/:id/images", h.UploadProductImage) + api.DELETE("/products/:id/images/:image_id", h.DeleteProductImage) + api.PUT("/products/:id/images/reorder", h.ReorderProductImages) + + api.GET("/categories", h.GetCategories) + api.POST("/categories", h.CreateCategory) +} +``` + +### 6j. `internal/repository/product_repository.go` — add image methods + +Implement `AddProductImage`, `GetProductImage`, `GetProductImages`, `DeleteProductImage`, `UpdateProductImagePositions`, and update `GetProductByID` to preload images: + +```go +// GetProductByID — add Preload for images, ordered by position +db.Preload("Images", func(db *gorm.DB) *gorm.DB { + return db.Order("position ASC") +}).Where("id = ? AND is_active = true", id).First(&product) + +// GetProducts — similarly preload Images ordered by position +``` + +### 6k. `internal/service/product_service.go` — implement image methods + +`AddProductImage`: verify product exists, get current max position, insert with `position = max + 1`. + +`DeleteProductImage`: verify image belongs to product, delete DB row, return the deleted image. + +`ReorderProductImages`: verify all IDs belong to the product, run a bulk UPDATE setting position = array index within a transaction. + +`DeleteProduct`: before deleting the product, fetch all images, delete GCS objects for each, then delete the DB row (CASCADE handles `product_images`). + +### 6l. `cmd/infrastructure.go` — add `setupGCS` + +```go +func setupGCS(ctx context.Context, bucketName, credJSON string) (domain.StorageService, error) { + if bucketName == "" { + log.Println("GCS_BUCKET_NAME not set — image upload disabled") + return domain.NoopStorage{}, nil + } + svc, err := gcsStorage.NewGCSStorage(ctx, bucketName, credJSON) + if err != nil { + return nil, fmt.Errorf("failed to initialise GCS: %w", err) + } + log.Printf("GCS storage initialised (bucket: %s)", bucketName) + return svc, nil +} +``` + +### 6m. `cmd/config.go` — add GCS fields + +```go +type appConfig struct { + Port string + DatabaseURL string + RedisURL string + KafkaBrokers string + GCSBucketName string + GCSCredentials string +} +``` + +### 6n. `cmd/run.go` — wire everything up + +```go +storageSvc, err := setupGCS(context.Background(), cfg.GCSBucketName, cfg.GCSCredentials) +if err != nil { + log.Fatalf("failed to set up GCS: %v", err) +} + +// Pass storage to both service (for DeleteProduct cleanup) and handler (for upload/delete) +svc := service.NewProductService(repo, productCache, publisher, storageSvc, cfg.GCSBucketName) +h := handler.NewProductHandler(svc, storageSvc, cfg.GCSBucketName) +``` + +### 6o. `cmd/infrastructure.go` — add `ProductImage` to AutoMigrate + +```go +if err := db.AutoMigrate(&domain.Category{}, &domain.Product{}, &domain.Inventory{}, &domain.ProductImage{}); err != nil { + return err +} +``` + +--- + +## 7. Environment Variables + +### `.env.example` + +```env +# ── GCS Image Storage ────────────────────────────────────────────────────────── +GCS_BUCKET_NAME=auron-product-images + +# Paste the raw JSON content of your service account key here. +# To prepare: cat gcs-credentials.json (copy the entire JSON, single or multi-line) +# Leave empty to disable image upload (service still starts without it). +GCS_CREDENTIALS_JSON= +``` + +### `docker-compose.yml` + +```yaml +product-service: + environment: + - PORT=8082 + - DATABASE_URL=postgres://auron:auron_pass@products-db:5432/products_db?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + - KAFKA_BROKERS=kafka:29092 + - GCS_BUCKET_NAME=${GCS_BUCKET_NAME} + - GCS_CREDENTIALS_JSON=${GCS_CREDENTIALS_JSON} +``` + +--- + +## 8. Gateway — confirm admin routing + +The gateway already routes `/products*` to the product-service. Confirm that `POST /products/:id/images`, `DELETE /products/:id/images/:image_id`, and `PUT /products/:id/images/reorder` are covered by the admin middleware (require `role = admin`). Check `services/api-gateway/routes/router.go`. + +--- + +## 9. Frontend Impact (Phase 7 — Admin Panel) + +### Updated `types/product.ts` + +```typescript +export interface ProductImage { + id: string + product_id: string + url: string + position: number + created_at: string +} + +export interface Product { + // ... existing fields ... + image_url: string // computed: images[0].url or "" + images: ProductImage[] // always an array, never null +} +``` + +Remove `image_url` from `productSchema` in `lib/validations/product.ts` — it's no longer a form field. + +### `lib/api/products.ts` additions + +```typescript +uploadProductImage(productId: string, file: File): Promise +deleteProductImage(productId: string, imageId: string): Promise +reorderProductImages(productId: string, imageIds: string[]): Promise +``` + +### Admin product form image section + +The product form splits into two steps: +1. Save product details → get `product_id` +2. Image manager panel: upload (file input), preview thumbnails, drag-to-reorder, delete button per image + +--- + +## 10. Implementation Order + +1. GCP setup (bucket, service account, key) — manual +2. `go get cloud.google.com/go/storage google.golang.org/api/option` +3. `internal/domain/storage.go` (interface + NoopStorage) +4. `internal/domain/errors.go` (add new errors) +5. `internal/domain/product.go` (add ProductImage, update Product, remove ImageURL, update DTOs) +6. `internal/domain/service.go` (add image methods) +7. `internal/domain/repository.go` (add image methods) +8. `internal/storage/gcs.go` +9. `internal/repository/product_repository.go` (implement image methods + update preloads) +10. `internal/service/product_service.go` (implement image methods + update DeleteProduct) +11. `internal/handler/product_handler.go` (add image handlers, update constructor) +12. `internal/route/product_route.go` (register new routes) +13. `cmd/config.go`, `cmd/infrastructure.go`, `cmd/run.go` (wire GCS) +14. `docker-compose.yml` + `.env.example` +15. Test with curl: + ```bash + # 1. Create a product (no image_url in body anymore) + curl -X POST http://localhost:8080/api/products \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"category_id":"...","name":"Test","description":"...","price":99.99}' + + # 2. Upload images + curl -X POST http://localhost:8080/api/products//images \ + -H "Authorization: Bearer " \ + -F "image=@photo1.jpg" + + curl -X POST http://localhost:8080/api/products//images \ + -H "Authorization: Bearer " \ + -F "image=@photo2.jpg" + + # 3. Reorder (first ID becomes primary) + curl -X PUT http://localhost:8080/api/products//images/reorder \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"image_ids":["",""]}' + + # 4. Get product — confirm images array + image_url + curl http://localhost:8080/api/products/ + ``` + +--- + +## 11. What Stays Unchanged + +- `GET /api/products` and `GET /api/products/:id` — same paths, enriched response (adds `images` array) +- `POST /api/categories`, `GET /api/categories` — not affected +- Inventory service, order service, payment service — not affected +- `image_url` in `ProductResponse` — still present, computed from `images[0]` diff --git a/services/product-service/cmd/config.go b/services/product-service/cmd/config.go index a822096..632ac71 100644 --- a/services/product-service/cmd/config.go +++ b/services/product-service/cmd/config.go @@ -3,10 +3,12 @@ package cmd import "os" type appConfig struct { - Port string - DatabaseURL string - RedisURL string - KafkaBrokers string + Port string + DatabaseURL string + RedisURL string + KafkaBrokers string + GCSBucketName string + GCSCredentials string } func loadConfig() appConfig { @@ -33,9 +35,11 @@ func loadConfig() appConfig { } return appConfig{ - Port: port, - DatabaseURL: databaseURL, - RedisURL: redisURL, - KafkaBrokers: kafkaBrokers, + Port: port, + DatabaseURL: databaseURL, + RedisURL: redisURL, + KafkaBrokers: kafkaBrokers, + GCSBucketName: os.Getenv("GCS_BUCKET_NAME"), + GCSCredentials: os.Getenv("GCS_CREDENTIALS_JSON"), } } diff --git a/services/product-service/cmd/infrastructure.go b/services/product-service/cmd/infrastructure.go index 884acc1..cbce347 100644 --- a/services/product-service/cmd/infrastructure.go +++ b/services/product-service/cmd/infrastructure.go @@ -1,12 +1,16 @@ package cmd import ( - "auron/product-service/internal/domain" "context" + "fmt" + "log" "os" "strings" "time" + "auron/product-service/internal/domain" + gcsStorage "auron/product-service/internal/storage" + "github.com/redis/go-redis/v9" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -33,12 +37,18 @@ func setupDatabase(databaseURL string) (*gorm.DB, error) { } func runMigrations(db *gorm.DB) error { - // Skip AutoMigrate if tables already exist — GORM generates malformed ALTER - // statements when column types use precision specifiers (e.g. numeric(12,2)) - // that differ only in name from what PostgreSQL reports. applySearchIndex - // is always re-run because its statements are idempotent (IF NOT EXISTS / OR REPLACE). if !db.Migrator().HasTable(&domain.Product{}) { - if err := db.AutoMigrate(&domain.Category{}, &domain.Product{}, &domain.Inventory{}); err != nil { + if err := db.AutoMigrate( + &domain.Category{}, + &domain.Product{}, + &domain.Inventory{}, + &domain.ProductImage{}, + ); err != nil { + return err + } + } else { + // Ensure product_images table is created even if products table already exists. + if err := db.AutoMigrate(&domain.ProductImage{}); err != nil { return err } } @@ -91,6 +101,21 @@ func setupRedis(redisURL string) (*redis.Client, error) { return client, nil } +func setupGCS(ctx context.Context, bucketName, credJSON string) (domain.StorageService, error) { + if bucketName == "" { + log.Println("GCS_BUCKET_NAME not set — image upload disabled") + return domain.NoopStorage{}, nil + } + + svc, err := gcsStorage.NewGCSStorage(ctx, bucketName, credJSON) + if err != nil { + return nil, fmt.Errorf("failed to initialise GCS: %w", err) + } + + log.Printf("GCS storage initialised (bucket: %s)", bucketName) + return svc, nil +} + func resolveGormLogLevel() logger.LogLevel { switch strings.ToLower(strings.TrimSpace(os.Getenv("GORM_LOG_LEVEL"))) { case "silent": diff --git a/services/product-service/cmd/run.go b/services/product-service/cmd/run.go index 241be65..3ec6d3a 100644 --- a/services/product-service/cmd/run.go +++ b/services/product-service/cmd/run.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "log" "os" @@ -37,10 +38,15 @@ func Run() { publisher := setupKafkaPublisher(cfg.KafkaBrokers) + storageSvc, err := setupGCS(context.Background(), cfg.GCSBucketName, cfg.GCSCredentials) + if err != nil { + log.Fatalf("failed to set up GCS: %v", err) + } + repo := repository.NewProductRepository(db) productCache := cache.NewProductCache(redisClient) - svc := service.NewProductService(repo, productCache, publisher) - h := handler.NewProductHandler(svc) + svc := service.NewProductService(repo, productCache, publisher, storageSvc) + h := handler.NewProductHandler(svc, storageSvc) router := setupRouter(h) registerGracefulShutdown(db, redisClient, publisher) diff --git a/services/product-service/go.mod b/services/product-service/go.mod index 3c6d975..457c4ab 100644 --- a/services/product-service/go.mod +++ b/services/product-service/go.mod @@ -3,18 +3,47 @@ module auron/product-service go 1.25.8 require ( + cloud.google.com/go/storage v1.62.2 + github.com/gin-gonic/gin v1.9.1 + github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.18.0 + github.com/segmentio/kafka-go v0.4.47 + google.golang.org/api v0.282.0 + gorm.io/driver/postgres v1.5.4 + gorm.io/gorm v1.31.1 +) + +require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.7.0 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect - github.com/gin-gonic/gin v1.9.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.4.3 // indirect @@ -29,19 +58,32 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect - github.com/redis/go-redis/v9 v9.18.0 // indirect - github.com/segmentio/kafka-go v0.4.47 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect - google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/postgres v1.5.4 // indirect - gorm.io/gorm v1.31.1 // indirect ) diff --git a/services/product-service/go.sum b/services/product-service/go.sum index 532b2a4..bde9793 100644 --- a/services/product-service/go.sum +++ b/services/product-service/go.sum @@ -1,3 +1,37 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= +cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= +cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= +cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4= +cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= @@ -6,16 +40,39 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -24,9 +81,21 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +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.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/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 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.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= 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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -44,6 +113,10 @@ github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHU github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= @@ -57,11 +130,19 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +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/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0= github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -72,14 +153,41 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= @@ -88,8 +196,8 @@ golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -98,11 +206,15 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -113,8 +225,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -127,18 +239,32 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 h1:aLsVTW0lZ8+IY5u/ERjZSCvAmhuR7slKzyha3YikDNA= -google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478/go.mod h1:YJAzKjfHIUHb9T+bfu8L7mthAp7VVXQBUs1PLdBWS7M= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/product-service/internal/domain/errors.go b/services/product-service/internal/domain/errors.go index 8aa0a0b..d64aa42 100644 --- a/services/product-service/internal/domain/errors.go +++ b/services/product-service/internal/domain/errors.go @@ -19,7 +19,12 @@ var ( ErrInvalidLimitParam = errors.New("limit must be >= 1 and <= 100") ErrPriceMustBePositive = errors.New("price must be a positive number") - //generic + // image errors + ErrImageNotFound = errors.New("image not found") + ErrStorageNotConfigured = errors.New("image storage is not configured") + ErrInvalidImageOrder = errors.New("image_ids must include all images for the product") + + // generic ErrUnauthorized = errors.New("unauthorized") ErrForbidden = errors.New("forbidden") ) \ No newline at end of file diff --git a/services/product-service/internal/domain/product.go b/services/product-service/internal/domain/product.go index 38c7cae..022c42c 100644 --- a/services/product-service/internal/domain/product.go +++ b/services/product-service/internal/domain/product.go @@ -38,27 +38,35 @@ type Category struct { CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` } -func (Category) TableName() string { - return "categories" +func (Category) TableName() string { return "categories" } + +// ProductImage represents a single image attached to a product. +// Position 0 is the primary (display) image shown on product cards. +type ProductImage struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;not null;index"` + URL string `json:"url" gorm:"type:text;not null"` + Position int `json:"position" gorm:"not null;default:0"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` } +func (ProductImage) TableName() string { return "product_images" } + type Product struct { - ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` - CategoryID uuid.UUID `json:"category_id" gorm:"type:uuid;not null;index"` - Name string `json:"name" gorm:"type:varchar(500);not null"` - Description string `json:"description" gorm:"type:text"` - Price float64 `json:"price" gorm:"type:numeric(12,2);not null;index"` - ImageURL string `json:"image_url" gorm:"type:text"` - SearchVector string `json:"-" gorm:"-"` - IsActive bool `json:"is_active" gorm:"not null;default:true;index"` - CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` - UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` - Category *Category `json:"category,omitempty" gorm:"foreignKey:CategoryID;references:ID"` + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + CategoryID uuid.UUID `json:"category_id" gorm:"type:uuid;not null;index"` + Name string `json:"name" gorm:"type:varchar(500);not null"` + Description string `json:"description" gorm:"type:text"` + Price float64 `json:"price" gorm:"type:numeric(12,2);not null;index"` + Images []ProductImage `json:"images,omitempty" gorm:"foreignKey:ProductID;references:ID;constraint:OnDelete:CASCADE"` + SearchVector string `json:"-" gorm:"-"` + IsActive bool `json:"is_active" gorm:"not null;default:true;index"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` + Category *Category `json:"category,omitempty" gorm:"foreignKey:CategoryID;references:ID"` } -func (Product) TableName() string { - return "products" -} +func (Product) TableName() string { return "products" } type Inventory struct { ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;primaryKey"` @@ -68,11 +76,8 @@ type Inventory struct { UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` } -func (Inventory) TableName() string { - return "inventory" -} +func (Inventory) TableName() string { return "inventory" } -// AvailableQuantity returns the stock available for purchase. func (i *Inventory) AvailableQuantity() int { return i.TotalQuantity - i.ReservedQuantity } @@ -87,13 +92,18 @@ type CategoryRequest struct { ParentID *uuid.UUID `json:"parent_id,omitempty"` } +// ProductRequest no longer includes ImageURL — images are managed via +// POST /products/:id/images after the product is created. type ProductRequest struct { - CategoryID uuid.UUID `json:"category_id" binding:"required"` - Name string `json:"name" binding:"required,max=500"` - Description string `json:"description" binding:"required"` - Price float64 `json:"price" binding:"required,gt=0"` - ImageURL string `json:"image_url" binding:"omitempty,url"` - IsActive *bool `json:"is_active"` + CategoryID uuid.UUID `json:"category_id" binding:"required"` + Name string `json:"name" binding:"required,max=500"` + Description string `json:"description" binding:"required"` + Price float64 `json:"price" binding:"required,gt=0"` + IsActive *bool `json:"is_active"` +} + +type ReorderImagesRequest struct { + ImageIDs []uuid.UUID `json:"image_ids" binding:"required,min=1"` } // ============================================================ @@ -109,16 +119,17 @@ type CategoryResponse struct { } type ProductResponse struct { - ID uuid.UUID `json:"id"` - CategoryID uuid.UUID `json:"category_id"` - Name string `json:"name"` - Description string `json:"description"` - Price float64 `json:"price"` - ImageURL string `json:"image_url,omitempty"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Category *Category `json:"category,omitempty"` + ID uuid.UUID `json:"id"` + CategoryID uuid.UUID `json:"category_id"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + ImageURL string `json:"image_url"` // computed: images[0].URL or "" + Images []ProductImage `json:"images"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Category *Category `json:"category,omitempty"` } type InventoryResponse struct { @@ -134,7 +145,6 @@ type InventoryResponse struct { // HELPER FUNCTIONS // ============================================================ -// ToResponse converts a Product entity to its response DTO. func (p *Product) ToResponse() *ProductResponse { resp := &ProductResponse{ ID: p.ID, @@ -142,20 +152,21 @@ func (p *Product) ToResponse() *ProductResponse { Name: p.Name, Description: p.Description, Price: p.Price, - ImageURL: p.ImageURL, + Images: p.Images, IsActive: p.IsActive, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, + Category: p.Category, } - - if p.Category != nil { - resp.Category = p.Category + if resp.Images == nil { + resp.Images = []ProductImage{} // always array, never null in JSON + } + if len(p.Images) > 0 { + resp.ImageURL = p.Images[0].URL // position 0 = primary } - return resp } -// ToResponse converts a Category entity to its response DTO. func (c *Category) ToResponse() *CategoryResponse { return &CategoryResponse{ ID: c.ID, @@ -166,7 +177,6 @@ func (c *Category) ToResponse() *CategoryResponse { } } -// ToResponse converts an Inventory entity to its response DTO. func (i *Inventory) ToResponse() *InventoryResponse { return &InventoryResponse{ ProductID: i.ProductID, diff --git a/services/product-service/internal/domain/repository.go b/services/product-service/internal/domain/repository.go index 1e71ae8..1dc1d14 100644 --- a/services/product-service/internal/domain/repository.go +++ b/services/product-service/internal/domain/repository.go @@ -14,22 +14,29 @@ type ProductFilter struct { type ProductListResponse struct { Products []Product - Total int64 - Page int - Limit int + Total int64 + Page int + Limit int } type ProductRepository interface { - // product operations + // Product operations GetProducts(filter ProductFilter) (*ProductListResponse, error) GetProductByID(id uuid.UUID) (*Product, error) CreateProduct(product *Product) (*Product, error) UpdateProduct(product *Product) (*Product, error) DeleteProduct(id uuid.UUID) error - // category operations + // Category operations GetCategories() ([]Category, error) GetCategoryByID(id uuid.UUID) (*Category, error) GetCategoryBySlug(slug string) (*Category, error) CreateCategory(category *Category) (*Category, error) + + // Image operations + AddProductImage(image *ProductImage) (*ProductImage, error) + GetProductImage(productID, imageID uuid.UUID) (*ProductImage, error) + GetProductImages(productID uuid.UUID) ([]ProductImage, error) + DeleteProductImage(productID, imageID uuid.UUID) error + UpdateProductImagePositions(images []ProductImage) error } diff --git a/services/product-service/internal/domain/service.go b/services/product-service/internal/domain/service.go index 6c91f4d..afd3402 100644 --- a/services/product-service/internal/domain/service.go +++ b/services/product-service/internal/domain/service.go @@ -2,10 +2,11 @@ package domain import ( "context" + "github.com/google/uuid" ) -// ProductService defines the business logic for products and categories. +// ProductService defines the business logic for products, categories, and images. type ProductService interface { // Product operations GetProducts(ctx context.Context, filter ProductFilter) (*ProductListResponse, error) @@ -19,4 +20,10 @@ type ProductService interface { GetCategoryByID(ctx context.Context, id uuid.UUID) (*Category, error) GetCategoryBySlug(ctx context.Context, slug string) (*Category, error) CreateCategory(ctx context.Context, req CategoryRequest) (*Category, error) + + // Image operations + AddProductImage(ctx context.Context, productID uuid.UUID, url string) (*ProductImage, error) + DeleteProductImage(ctx context.Context, productID, imageID uuid.UUID) (*ProductImage, error) + ReorderProductImages(ctx context.Context, productID uuid.UUID, imageIDs []uuid.UUID) ([]ProductImage, error) + GetProductImages(ctx context.Context, productID uuid.UUID) ([]ProductImage, error) } diff --git a/services/product-service/internal/domain/storage.go b/services/product-service/internal/domain/storage.go new file mode 100644 index 0000000..32fc699 --- /dev/null +++ b/services/product-service/internal/domain/storage.go @@ -0,0 +1,27 @@ +package domain + +import ( + "context" + "io" +) + +// StorageService manages file storage for product assets. +type StorageService interface { + UploadImage(ctx context.Context, objectName string, r io.Reader, contentType string) (publicURL string, err error) + DeleteImage(ctx context.Context, objectName string) error + // ObjectNameFromURL extracts the storage object path from a full public URL. + // Returns ("", false) for URLs that don't belong to this storage backend. + ObjectNameFromURL(url string) (string, bool) +} + +// NoopStorage is used when GCS credentials are not configured. +// UploadImage always returns ErrStorageNotConfigured; the other methods are no-ops. +type NoopStorage struct{} + +func (NoopStorage) UploadImage(_ context.Context, _ string, _ io.Reader, _ string) (string, error) { + return "", ErrStorageNotConfigured +} + +func (NoopStorage) DeleteImage(_ context.Context, _ string) error { return nil } + +func (NoopStorage) ObjectNameFromURL(_ string) (string, bool) { return "", false } diff --git a/services/product-service/internal/handler/product_handler.go b/services/product-service/internal/handler/product_handler.go index c8c6f16..7b27fef 100644 --- a/services/product-service/internal/handler/product_handler.go +++ b/services/product-service/internal/handler/product_handler.go @@ -3,6 +3,8 @@ package handler import ( "errors" "fmt" + "io" + "log/slog" "net/http" "strconv" @@ -12,12 +14,18 @@ import ( "github.com/google/uuid" ) +var ( + allowedMIME = map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} + maxImageBytes = int64(5 << 20) // 5 MB +) + type ProductHandler struct { service domain.ProductService + storage domain.StorageService } -func NewProductHandler(service domain.ProductService) *ProductHandler { - return &ProductHandler{service: service} +func NewProductHandler(service domain.ProductService, storage domain.StorageService) *ProductHandler { + return &ProductHandler{service: service, storage: storage} } // ── Product handlers ────────────────────────────────────────────────────────── @@ -35,9 +43,14 @@ func (h *ProductHandler) GetProducts(c *gin.Context) { return } + responses := make([]*domain.ProductResponse, len(result.Products)) + for i := range result.Products { + responses[i] = result.Products[i].ToResponse() + } + c.JSON(http.StatusOK, gin.H{ "success": true, - "data": result.Products, + "data": responses, "meta": gin.H{ "page": result.Page, "limit": result.Limit, @@ -155,15 +168,118 @@ func (h *ProductHandler) CreateCategory(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"success": true, "data": category.ToResponse()}) } +// ── Image handlers ──────────────────────────────────────────────────────────── + +func (h *ProductHandler) UploadProductImage(c *gin.Context) { + productID, err := parseUUID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxImageBytes) + file, header, err := c.Request.FormFile("image") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "image field is required"}) + return + } + defer file.Close() + + if header.Size > maxImageBytes { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "image must be 5 MB or smaller"}) + return + } + + // Detect MIME from first 512 bytes, then seek back. + buf := make([]byte, 512) + n, _ := file.Read(buf) + contentType := http.DetectContentType(buf[:n]) + ext, ok := allowedMIME[contentType] + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "only JPEG, PNG, and WebP images are accepted"}) + return + } + file.Seek(0, io.SeekStart) // multipart.File implements io.Seeker + + objectName := fmt.Sprintf("products/%s%s", uuid.New().String(), ext) + publicURL, err := h.storage.UploadImage(c.Request.Context(), objectName, file, contentType) + if err != nil { + if errors.Is(err, domain.ErrStorageNotConfigured) { + c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "error": "image storage is not configured"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to upload image"}) + return + } + + image, err := h.service.AddProductImage(c.Request.Context(), productID, publicURL) + if err != nil { + // GCS upload succeeded but DB insert failed — clean up the orphan object. + _ = h.storage.DeleteImage(c.Request.Context(), objectName) + h.handleError(c, err) + return + } + + c.JSON(http.StatusCreated, gin.H{"success": true, "data": image}) +} + +func (h *ProductHandler) DeleteProductImage(c *gin.Context) { + productID, err := parseUUID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + imageID, err := parseUUID(c.Param("image_id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid image id"}) + return + } + + deleted, err := h.service.DeleteProductImage(c.Request.Context(), productID, imageID) + if err != nil { + h.handleError(c, err) + return + } + + // Best-effort GCS cleanup — don't fail the request if storage is slow. + if objName, ok := h.storage.ObjectNameFromURL(deleted.URL); ok { + if err := h.storage.DeleteImage(c.Request.Context(), objName); err != nil { + slog.Warn("failed to delete GCS object", "url", deleted.URL, "error", err) + } + } + + c.JSON(http.StatusOK, gin.H{"success": true, "message": "image deleted"}) +} + +func (h *ProductHandler) ReorderProductImages(c *gin.Context) { + productID, err := parseUUID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + + var req domain.ReorderImagesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + images, err := h.service.ReorderProductImages(c.Request.Context(), productID, req.ImageIDs) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": images}) +} + // ── Input DTO ───────────────────────────────────────────────────────────────── -// productBody is the HTTP request body for product create/update. type productBody struct { CategoryID string `json:"category_id" binding:"required"` Name string `json:"name" binding:"required,max=500"` Description string `json:"description" binding:"required"` Price float64 `json:"price" binding:"required,gt=0"` - ImageURL string `json:"image_url" binding:"omitempty,url"` IsActive *bool `json:"is_active"` } @@ -172,13 +288,11 @@ func (b *productBody) toDomain() (domain.ProductRequest, error) { if err != nil { return domain.ProductRequest{}, fmt.Errorf("invalid category_id: %w", err) } - return domain.ProductRequest{ CategoryID: categoryID, Name: b.Name, Description: b.Description, Price: b.Price, - ImageURL: b.ImageURL, IsActive: b.IsActive, }, nil } @@ -191,13 +305,18 @@ func (h *ProductHandler) handleError(c *gin.Context, err error) { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) case errors.Is(err, domain.ErrCategoryNotFound): c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrImageNotFound): + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) case errors.Is(err, domain.ErrCategorySlugExists), errors.Is(err, domain.ErrProductAlreadyExists): c.JSON(http.StatusConflict, gin.H{"success": false, "error": err.Error()}) case errors.Is(err, domain.ErrInvalidSortParam), errors.Is(err, domain.ErrInvalidPageParam), errors.Is(err, domain.ErrInvalidLimitParam), - errors.Is(err, domain.ErrPriceMustBePositive): + errors.Is(err, domain.ErrPriceMustBePositive), + errors.Is(err, domain.ErrInvalidImageOrder): c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrStorageNotConfigured): + c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "error": err.Error()}) case errors.Is(err, domain.ErrUnauthorized): c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": err.Error()}) case errors.Is(err, domain.ErrForbidden): diff --git a/services/product-service/internal/repository/product_repository.go b/services/product-service/internal/repository/product_repository.go index e13d92f..f0aa216 100644 --- a/services/product-service/internal/repository/product_repository.go +++ b/services/product-service/internal/repository/product_repository.go @@ -15,28 +15,24 @@ func NewProductRepository(db *gorm.DB) domain.ProductRepository { return &ProductRepository{db: db} } +// ── Product operations ──────────────────────────────────────────────────────── + func (r *ProductRepository) GetProducts(filter domain.ProductFilter) (*domain.ProductListResponse, error) { query := r.db.Model(&domain.Product{}).Where("is_active = ?", true) - // filter by category if filter.CategoryID != nil { query = query.Where("category_id = ?", *filter.CategoryID) } - - // price range filters if filter.MinPrice != nil { query = query.Where("price >= ?", *filter.MinPrice) } if filter.MaxPrice != nil { query = query.Where("price <= ?", *filter.MaxPrice) } - - // full text search if filter.Q != "" { query = query.Where("search_vector @@ plainto_tsquery('english', ?)", &filter.Q) } - // apply sorting query = r.applySort(query, filter.Sort) var total int64 @@ -44,13 +40,17 @@ func (r *ProductRepository) GetProducts(filter domain.ProductFilter) (*domain.Pr return nil, err } - // apply pagination offset := (filter.Page - 1) * filter.Limit query = query.Offset(offset).Limit(filter.Limit) - // execite query with category preload var products []domain.Product - if err := query.Preload("Category").Find(&products).Error; err != nil { + err := query. + Preload("Category"). + Preload("Images", func(db *gorm.DB) *gorm.DB { + return db.Order("position ASC") + }). + Find(&products).Error + if err != nil { return nil, err } @@ -64,7 +64,13 @@ func (r *ProductRepository) GetProducts(filter domain.ProductFilter) (*domain.Pr func (r *ProductRepository) GetProductByID(id uuid.UUID) (*domain.Product, error) { var product domain.Product - if err := r.db.Preload("Category").First(&product, "id = ? AND is_active = ?", id, true).Error; err != nil { + err := r.db. + Preload("Category"). + Preload("Images", func(db *gorm.DB) *gorm.DB { + return db.Order("position ASC") + }). + First(&product, "id = ? AND is_active = ?", id, true).Error + if err != nil { if err == gorm.ErrRecordNotFound { return nil, domain.ErrProductNotFound } @@ -88,12 +94,11 @@ func (r *ProductRepository) UpdateProduct(product *domain.Product) (*domain.Prod } func (r *ProductRepository) DeleteProduct(id uuid.UUID) error { - if err := r.db.Where("id = ?", id).Delete(&domain.Product{}).Error; err != nil { - return err - } - return nil + return r.db.Where("id = ?", id).Delete(&domain.Product{}).Error } +// ── Category operations ─────────────────────────────────────────────────────── + func (r *ProductRepository) GetCategories() ([]domain.Category, error) { var categories []domain.Category if err := r.db.Find(&categories).Error; err != nil { @@ -131,25 +136,67 @@ func (r *ProductRepository) CreateCategory(category *domain.Category) (*domain.C return category, nil } +// ── Image operations ────────────────────────────────────────────────────────── + +func (r *ProductRepository) AddProductImage(image *domain.ProductImage) (*domain.ProductImage, error) { + if err := r.db.Create(image).Error; err != nil { + return nil, err + } + return image, nil +} + +func (r *ProductRepository) GetProductImage(productID, imageID uuid.UUID) (*domain.ProductImage, error) { + var image domain.ProductImage + err := r.db.First(&image, "id = ? AND product_id = ?", imageID, productID).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, domain.ErrImageNotFound + } + return nil, err + } + return &image, nil +} + +func (r *ProductRepository) GetProductImages(productID uuid.UUID) ([]domain.ProductImage, error) { + var images []domain.ProductImage + if err := r.db.Where("product_id = ?", productID).Order("position ASC").Find(&images).Error; err != nil { + return nil, err + } + return images, nil +} + +func (r *ProductRepository) DeleteProductImage(productID, imageID uuid.UUID) error { + return r.db.Where("id = ? AND product_id = ?", imageID, productID).Delete(&domain.ProductImage{}).Error +} + +func (r *ProductRepository) UpdateProductImagePositions(images []domain.ProductImage) error { + return r.db.Transaction(func(tx *gorm.DB) error { + for _, img := range images { + if err := tx.Model(&domain.ProductImage{}). + Where("id = ?", img.ID). + Update("position", img.Position).Error; err != nil { + return err + } + } + return nil + }) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + func (r *ProductRepository) applySort(query *gorm.DB, sort string) *gorm.DB { switch sort { case "price_asc": return query.Order("price ASC") - case "price_desc": return query.Order("price DESC") - case "newest": return query.Order("created_at DESC") - case "name_asc": return query.Order("name ASC") - case "name_desc": return query.Order("name DESC") - default: - return query.Order("created_at DESC") // default newest first + return query.Order("created_at DESC") } - } diff --git a/services/product-service/internal/route/product_route.go b/services/product-service/internal/route/product_route.go index 0d75b13..8bd7a40 100644 --- a/services/product-service/internal/route/product_route.go +++ b/services/product-service/internal/route/product_route.go @@ -15,6 +15,11 @@ func RegisterProductRoutes(router *gin.Engine, h *handler.ProductHandler) { api.PUT("/products/:id", h.UpdateProduct) api.DELETE("/products/:id", h.DeleteProduct) + // Image management (admin only — enforced by gateway) + api.POST("/products/:id/images", h.UploadProductImage) + api.DELETE("/products/:id/images/:image_id", h.DeleteProductImage) + api.PUT("/products/:id/images/reorder", h.ReorderProductImages) + api.GET("/categories", h.GetCategories) api.POST("/categories", h.CreateCategory) } diff --git a/services/product-service/internal/service/product_service.go b/services/product-service/internal/service/product_service.go index 2e40143..580be1a 100644 --- a/services/product-service/internal/service/product_service.go +++ b/services/product-service/internal/service/product_service.go @@ -15,13 +15,20 @@ type ProductService struct { repository domain.ProductRepository cache domain.ProductCache publisher domain.EventPublisher + storage domain.StorageService } -func NewProductService(repo domain.ProductRepository, cache domain.ProductCache, publisher domain.EventPublisher) domain.ProductService { +func NewProductService( + repo domain.ProductRepository, + cache domain.ProductCache, + publisher domain.EventPublisher, + storage domain.StorageService, +) domain.ProductService { return &ProductService{ repository: repo, cache: cache, publisher: publisher, + storage: storage, } } @@ -92,7 +99,6 @@ func (s *ProductService) CreateProduct(ctx context.Context, req domain.ProductRe Name: req.Name, Description: req.Description, Price: req.Price, - ImageURL: req.ImageURL, IsActive: true, CreatedAt: now, UpdatedAt: now, @@ -138,7 +144,6 @@ func (s *ProductService) UpdateProduct(ctx context.Context, id uuid.UUID, req do existing.Name = req.Name existing.Description = req.Description existing.Price = req.Price - existing.ImageURL = req.ImageURL existing.UpdatedAt = time.Now() if req.IsActive != nil { existing.IsActive = *req.IsActive @@ -170,10 +175,22 @@ func (s *ProductService) DeleteProduct(ctx context.Context, id uuid.UUID) error return err } + // Fetch images before DB delete for GCS cleanup. + images, _ := s.repository.GetProductImages(id) // best-effort; errors are non-fatal + if err := s.repository.DeleteProduct(id); err != nil { return err } + // Delete GCS objects for all product images — best-effort after DB row is gone. + for _, img := range images { + if objName, ok := s.storage.ObjectNameFromURL(img.URL); ok { + if err := s.storage.DeleteImage(ctx, objName); err != nil { + slog.Warn("failed to delete GCS object on product delete", "url", img.URL, "error", err) + } + } + } + if err := s.cache.DeleteProduct(ctx, id.String()); err != nil { slog.Warn("failed to evict product from cache", "product_id", id, "error", err) } @@ -207,6 +224,92 @@ func (s *ProductService) CreateCategory(ctx context.Context, req domain.Category return s.repository.CreateCategory(category) } +// ── Image methods ───────────────────────────────────────────────────────────── + +func (s *ProductService) AddProductImage(ctx context.Context, productID uuid.UUID, url string) (*domain.ProductImage, error) { + if _, err := s.repository.GetProductByID(productID); err != nil { + return nil, err + } + + existing, err := s.repository.GetProductImages(productID) + if err != nil { + return nil, err + } + + image := &domain.ProductImage{ + ID: uuid.New(), + ProductID: productID, + URL: url, + Position: len(existing), // append after current last + CreatedAt: time.Now(), + } + + created, err := s.repository.AddProductImage(image) + if err != nil { + return nil, err + } + + _ = s.cache.DeleteProduct(ctx, productID.String()) + _ = s.cache.InvalidateProductList(ctx) + + return created, nil +} + +func (s *ProductService) DeleteProductImage(ctx context.Context, productID, imageID uuid.UUID) (*domain.ProductImage, error) { + image, err := s.repository.GetProductImage(productID, imageID) + if err != nil { + return nil, err + } + + if err := s.repository.DeleteProductImage(productID, imageID); err != nil { + return nil, err + } + + _ = s.cache.DeleteProduct(ctx, productID.String()) + _ = s.cache.InvalidateProductList(ctx) + + return image, nil +} + +func (s *ProductService) ReorderProductImages(ctx context.Context, productID uuid.UUID, imageIDs []uuid.UUID) ([]domain.ProductImage, error) { + existing, err := s.repository.GetProductImages(productID) + if err != nil { + return nil, err + } + + if len(imageIDs) != len(existing) { + return nil, domain.ErrInvalidImageOrder + } + + imageMap := make(map[uuid.UUID]*domain.ProductImage, len(existing)) + for i := range existing { + imageMap[existing[i].ID] = &existing[i] + } + + reordered := make([]domain.ProductImage, 0, len(imageIDs)) + for pos, id := range imageIDs { + img, ok := imageMap[id] + if !ok { + return nil, domain.ErrImageNotFound + } + img.Position = pos + reordered = append(reordered, *img) + } + + if err := s.repository.UpdateProductImagePositions(reordered); err != nil { + return nil, err + } + + _ = s.cache.DeleteProduct(ctx, productID.String()) + _ = s.cache.InvalidateProductList(ctx) + + return reordered, nil +} + +func (s *ProductService) GetProductImages(ctx context.Context, productID uuid.UUID) ([]domain.ProductImage, error) { + return s.repository.GetProductImages(productID) +} + // ── Helpers ─────────────────────────────────────────────────────────────────── func normalizeFilter(f *domain.ProductFilter) error { @@ -228,8 +331,6 @@ func normalizeFilter(f *domain.ProductFilter) error { return nil } -// buildListCacheKey produces a deterministic cache key for a product list query. -// All pointer fields are nil-safe. func buildListCacheKey(f domain.ProductFilter) string { categoryID := "" if f.CategoryID != nil { @@ -246,4 +347,4 @@ func buildListCacheKey(f domain.ProductFilter) string { return fmt.Sprintf("product:list:%s_%s_%s_%s_%d_%d", f.Q, categoryID, minPrice, maxPrice, f.Page, f.Limit, ) -} \ No newline at end of file +} diff --git a/services/product-service/internal/storage/gcs.go b/services/product-service/internal/storage/gcs.go new file mode 100644 index 0000000..7025671 --- /dev/null +++ b/services/product-service/internal/storage/gcs.go @@ -0,0 +1,71 @@ +package storage + +import ( + "context" + "fmt" + "io" + "strings" + + "cloud.google.com/go/storage" + "google.golang.org/api/option" +) + +// GCSStorage implements domain.StorageService backed by Google Cloud Storage. +type GCSStorage struct { + client *storage.Client + bucketName string +} + +// NewGCSStorage creates a GCS client from a raw service-account JSON credential string. +// If credJSON is empty the SDK falls back to Application Default Credentials +// (GOOGLE_APPLICATION_CREDENTIALS env var or the GCP metadata server). +func NewGCSStorage(ctx context.Context, bucketName, credJSON string) (*GCSStorage, error) { + var opts []option.ClientOption + if credJSON != "" { + opts = append(opts, option.WithCredentialsJSON([]byte(credJSON))) + } + + client, err := storage.NewClient(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("gcs: failed to create client: %w", err) + } + + return &GCSStorage{client: client, bucketName: bucketName}, nil +} + +// UploadImage streams r to GCS at objectName and returns the public HTTPS URL. +func (g *GCSStorage) UploadImage(ctx context.Context, objectName string, r io.Reader, contentType string) (string, error) { + obj := g.client.Bucket(g.bucketName).Object(objectName) + w := obj.NewWriter(ctx) + w.ContentType = contentType + w.CacheControl = "public, max-age=31536000" // 1 year — objects are UUID-named, never stale + + if _, err := io.Copy(w, r); err != nil { + _ = w.Close() + return "", fmt.Errorf("gcs: upload failed: %w", err) + } + if err := w.Close(); err != nil { + return "", fmt.Errorf("gcs: finalise upload failed: %w", err) + } + + return fmt.Sprintf("https://storage.googleapis.com/%s/%s", g.bucketName, objectName), nil +} + +// DeleteImage removes objectName from GCS. Returns nil if the object does not exist. +func (g *GCSStorage) DeleteImage(ctx context.Context, objectName string) error { + err := g.client.Bucket(g.bucketName).Object(objectName).Delete(ctx) + if err == storage.ErrObjectNotExist { + return nil + } + return err +} + +// ObjectNameFromURL extracts the GCS object path from a full public URL. +// Returns ("", false) for URLs that don't match this bucket. +func (g *GCSStorage) ObjectNameFromURL(url string) (string, bool) { + prefix := fmt.Sprintf("https://storage.googleapis.com/%s/", g.bucketName) + if strings.HasPrefix(url, prefix) { + return strings.TrimPrefix(url, prefix), true + } + return "", false +} From ee259a65eb9c93e0e88b0955fb5f8ea2e2fc4ee4 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Sat, 30 May 2026 13:26:56 +0700 Subject: [PATCH 2/3] fix: add image routes to gateway and fix product_images migration Gateway was missing the three image management routes, causing 404s. Added POST/DELETE/PUT for /:id/images and /:id/images/reorder under the admin-protected products group. Migration was trying to call db.AutoMigrate(&ProductImage{}) in isolation, which caused GORM to traverse relationships and generate invalid SQL ("insufficient arguments"). Replaced with idempotent raw SQL in applySearchIndex so product_images is always created safely regardless of whether the products table already existed. Also updated API_DOCS.md and API_CURL_TESTS.md with the new image endpoints, updated product response shape (images array + computed image_url), and removed image_url from the create/update request body. Co-Authored-By: Claude Sonnet 4.6 --- docs/API_CURL_TESTS.md | 112 ++++++++++++++-- docs/API_DOCS.md | 124 +++++++++++++++--- services/api-gateway/routes/router.go | 5 + .../product-service/cmd/infrastructure.go | 22 +++- 4 files changed, 227 insertions(+), 36 deletions(-) diff --git a/docs/API_CURL_TESTS.md b/docs/API_CURL_TESTS.md index ac36375..08e9b4d 100644 --- a/docs/API_CURL_TESTS.md +++ b/docs/API_CURL_TESTS.md @@ -277,6 +277,8 @@ CATEGORY_ID=$(curl -s -X POST $BASE/categories \ ### Create — admin only, save ID +> `image_url` removed from request body — upload images separately after creation. + ```bash PRODUCT_ID=$(curl -s -X POST $BASE/products \ -H "Authorization: Bearer $ADMIN_TOKEN" \ @@ -288,23 +290,112 @@ PRODUCT_ID=$(curl -s -X POST $BASE/products \ \"price\": 15999000, \"is_active\": true }" | jq -r '.data.id') +echo "Product ID: $PRODUCT_ID" ``` **Response:** ```json { - "data": { "id": "", "name": "iPhone 15 Pro", "price": 15999000, "is_active": true, ... }, - "success": true + "success": true, + "data": { "id": "", "name": "iPhone 15 Pro", "price": 15999000, "image_url": "", "images": [], "is_active": true, ... } +} +``` + +--- + +### Upload image — admin only, save first image ID + +```bash +IMAGE_ID=$(curl -s -X POST "$BASE/products/$PRODUCT_ID/images" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -F "image=@/path/to/iphone.jpg" | jq -r '.data.id') +echo "Image ID: $IMAGE_ID" +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": "", + "product_id": "", + "url": "https://storage.googleapis.com/auron-product-images/products/.jpg", + "position": 0, + "created_at": "2026-01-01T00:00:00Z" + } } ``` --- +### Upload second image + +```bash +IMAGE_ID_2=$(curl -s -X POST "$BASE/products/$PRODUCT_ID/images" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -F "image=@/path/to/iphone-back.jpg" | jq -r '.data.id') +echo "Image 2 ID: $IMAGE_ID_2" +``` + +--- + +### Get product — confirm images array and image_url + +```bash +curl -s "$BASE/products/$PRODUCT_ID" | jq '.data | {image_url, images}' +``` + +**Response:** +```json +{ + "image_url": "https://storage.googleapis.com/auron-product-images/products/.jpg", + "images": [ + { "id": "", "url": "https://...", "position": 0, "created_at": "..." }, + { "id": "", "url": "https://...", "position": 1, "created_at": "..." } + ] +} +``` + +--- + +### Reorder images — make second image primary + +```bash +curl -s -X PUT "$BASE/products/$PRODUCT_ID/images/reorder" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"image_ids\":[\"$IMAGE_ID_2\",\"$IMAGE_ID\"]}" | jq '.data[] | {id,position}' +``` + +**Response:** +```json +[ + { "id": "", "position": 0 }, + { "id": "", "position": 1 } +] +``` + +--- + +### Delete image — admin only + +```bash +curl -s -X DELETE "$BASE/products/$PRODUCT_ID/images/$IMAGE_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq +``` + +**Response:** +```json +{ "success": true, "message": "image deleted" } +``` + +--- + ### List — public, with filters ```bash # All products -curl -s "$BASE/products" | jq '.data[0] | {id,name,price}' +curl -s "$BASE/products" | jq '.data[0] | {id, name, price, image_url, images_count: (.images | length)}' # Full-text search curl -s "$BASE/products?q=iphone" | jq '{total: .meta.total, first: .data[0].name}' @@ -316,9 +407,9 @@ curl -s "$BASE/products?category_id=$CATEGORY_ID&sort=price_asc&page=1&limit=5" **Response (list):** ```json { - "data": [{ "id": "", "name": "iPhone 15 Pro", "price": 15999000, "is_active": true, ... }], - "meta": { "page": 1, "limit": 20, "total": 1 }, - "success": true + "success": true, + "data": [{ "id": "", "name": "iPhone 15 Pro", "price": 15999000, "image_url": "https://...", "images": [...], ... }], + "meta": { "page": 1, "limit": 20, "total": 1 } } ``` @@ -327,15 +418,12 @@ curl -s "$BASE/products?category_id=$CATEGORY_ID&sort=price_asc&page=1&limit=5" ### Get by ID — public ```bash -curl -s "$BASE/products/$PRODUCT_ID" | jq '.data | {id,name,price}' +curl -s "$BASE/products/$PRODUCT_ID" | jq '.data | {id, name, price, image_url}' ``` **Response:** ```json -{ - "data": { "id": "", "name": "iPhone 15 Pro", "price": 15999000, ... }, - "success": true -} +{ "id": "", "name": "iPhone 15 Pro", "price": 15999000, "image_url": "https://..." } ``` --- @@ -356,7 +444,7 @@ curl -s -X PUT "$BASE/products/$PRODUCT_ID" \ --- -### Delete — admin only +### Delete — admin only (also deletes all GCS images) ```bash curl -s -X DELETE "$BASE/products/$PRODUCT_ID" -H "Authorization: Bearer $ADMIN_TOKEN" | jq diff --git a/docs/API_DOCS.md b/docs/API_DOCS.md index ba82cde..b846a1d 100644 --- a/docs/API_DOCS.md +++ b/docs/API_DOCS.md @@ -10,6 +10,7 @@ All endpoints are prefixed with `/api`. - [Authentication](#authentication) - [Users](#users) - [Products](#products) +- [Product Images](#product-images) - [Categories](#categories) - [Cart](#cart) - [Orders](#orders) @@ -344,6 +345,8 @@ All fields are optional — only provided fields are updated. GET endpoints are **public** (no auth required). POST, PUT, DELETE require **admin** role. +> **Image note:** Products support multiple ordered images via a separate `product_images` table. The `image_url` field in all product responses is a computed convenience value equal to `images[0].url` (the primary image). Use the [Image endpoints](#product-images) to upload, delete, and reorder images. + --- ### List Products @@ -375,22 +378,18 @@ Supports full-text search, filtering by category and price range, sorting, and p "name": "Product Name", "description": "...", "price": 99.99, - "image_url": "https://...", + "image_url": "https://storage.googleapis.com/bucket/products/uuid.jpg", + "images": [ + { "id": "uuid", "product_id": "uuid", "url": "https://...", "position": 0, "created_at": "..." }, + { "id": "uuid", "product_id": "uuid", "url": "https://...", "position": 1, "created_at": "..." } + ], "is_active": true, "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", - "category": { - "id": "uuid", - "name": "Electronics", - "slug": "electronics" - } + "category": { "id": "uuid", "name": "Electronics", "slug": "electronics" } } ], - "meta": { - "page": 1, - "limit": 20, - "total": 42 - } + "meta": { "page": 1, "limit": 20, "total": 42 } } ``` @@ -410,7 +409,10 @@ Supports full-text search, filtering by category and price range, sorting, and p "name": "Product Name", "description": "...", "price": 99.99, - "image_url": "https://...", + "image_url": "https://storage.googleapis.com/bucket/products/uuid.jpg", + "images": [ + { "id": "uuid", "product_id": "uuid", "url": "https://...", "position": 0, "created_at": "..." } + ], "is_active": true, "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" @@ -427,6 +429,8 @@ Supports full-text search, filtering by category and price range, sorting, and p `POST /api/products` **Admin only.** +Images are **not** included in this request. After creating a product, upload images separately via `POST /api/products/:id/images`. + **Request body:** ```json { @@ -434,7 +438,6 @@ Supports full-text search, filtering by category and price range, sorting, and p "name": "Product Name", "description": "Product description", "price": 99.99, - "image_url": "https://example.com/image.jpg", "is_active": true } ``` @@ -445,10 +448,9 @@ Supports full-text search, filtering by category and price range, sorting, and p | `name` | string | yes | max 500 chars | | `description` | string | yes | — | | `price` | float | yes | greater than 0 | -| `image_url` | string | no | valid URL | | `is_active` | bool | no | defaults to `true` | -**Response `201`:** same shape as Get Product +**Response `201`:** same shape as Get Product (`images` will be `[]`) **Errors:** `400` validation · `401` unauthenticated · `403` not admin · `404` category not found · `409` product already exists @@ -459,7 +461,7 @@ Supports full-text search, filtering by category and price range, sorting, and p `PUT /api/products/:id` **Admin only.** -**Request body:** same as Create Product +**Request body:** same as Create Product (no `image_url` — manage images via the image endpoints) **Response `200`:** same shape as Get Product @@ -472,15 +474,101 @@ Supports full-text search, filtering by category and price range, sorting, and p `DELETE /api/products/:id` **Admin only.** +Deletes the product and all its GCS image objects. + **Response `200`:** ```json +{ "success": true, "message": "product deleted" } +``` + +**Errors:** `400` · `401` · `403` · `404` + +--- + +## Product Images + +All image endpoints require **admin** role. Images are ordered by `position`; position `0` is the primary image shown on product cards (`image_url` in the product response). + +--- + +### Upload Image + +`POST /api/products/:id/images` +**Admin only.** + +Upload a file and attach it to the product. The image is stored in Google Cloud Storage and appended after the existing images. + +**Request:** `multipart/form-data` + +| Field | Type | Required | Constraints | +|-------|------|----------|-------------| +| `image` | file | yes | JPEG, PNG, or WebP · max 5 MB | + +**Response `201`:** +```json { "success": true, - "message": "product deleted" + "data": { + "id": "uuid", + "product_id": "uuid", + "url": "https://storage.googleapis.com/auron-product-images/products/uuid.jpg", + "position": 0, + "created_at": "2026-01-01T00:00:00Z" + } } ``` -**Errors:** `400` · `401` · `403` · `404` +**Errors:** `400` missing field / wrong MIME / size exceeded · `401` · `403` · `404` product not found · `503` GCS not configured + +--- + +### Delete Image + +`DELETE /api/products/:id/images/:image_id` +**Admin only.** + +Removes the image from GCS and the database. Remaining images are **not** automatically re-sequenced — call reorder if needed. + +**Response `200`:** +```json +{ "success": true, "message": "image deleted" } +``` + +**Errors:** `400` · `401` · `403` · `404` product or image not found + +--- + +### Reorder Images + +`PUT /api/products/:id/images/reorder` +**Admin only.** + +Sets the display order by providing all image IDs for the product in the desired order. The first ID becomes position `0` (primary image). + +**Request body:** +```json +{ + "image_ids": ["uuid-1", "uuid-2", "uuid-3"] +} +``` + +| Field | Type | Required | Constraints | +|-------|------|----------|-------------| +| `image_ids` | UUID[] | yes | must include **all** image IDs for this product | + +**Response `200`:** +```json +{ + "success": true, + "data": [ + { "id": "uuid-1", "product_id": "uuid", "url": "https://...", "position": 0, "created_at": "..." }, + { "id": "uuid-2", "product_id": "uuid", "url": "https://...", "position": 1, "created_at": "..." }, + { "id": "uuid-3", "product_id": "uuid", "url": "https://...", "position": 2, "created_at": "..." } + ] +} +``` + +**Errors:** `400` wrong number of IDs or ID not found · `401` · `403` · `404` --- diff --git a/services/api-gateway/routes/router.go b/services/api-gateway/routes/router.go index 332219c..53aa8df 100644 --- a/services/api-gateway/routes/router.go +++ b/services/api-gateway/routes/router.go @@ -101,6 +101,11 @@ func Setup(router *gin.Engine, cfg *config.Config) error { adminProducts.POST("", toProductService) adminProducts.PUT("/:id", toProductService) adminProducts.DELETE("/:id", toProductService) + + // Image management + adminProducts.POST("/:id/images", toProductService) + adminProducts.DELETE("/:id/images/:image_id", toProductService) + adminProducts.PUT("/:id/images/reorder", toProductService) } // Category routes — public read, admin write diff --git a/services/product-service/cmd/infrastructure.go b/services/product-service/cmd/infrastructure.go index cbce347..1449220 100644 --- a/services/product-service/cmd/infrastructure.go +++ b/services/product-service/cmd/infrastructure.go @@ -37,27 +37,37 @@ func setupDatabase(databaseURL string) (*gorm.DB, error) { } func runMigrations(db *gorm.DB) error { + // Skip AutoMigrate if tables already exist — GORM generates malformed ALTER + // statements when column types use precision specifiers (e.g. numeric(12,2)) + // that differ only in name from what PostgreSQL reports. if !db.Migrator().HasTable(&domain.Product{}) { if err := db.AutoMigrate( &domain.Category{}, &domain.Product{}, &domain.Inventory{}, - &domain.ProductImage{}, ); err != nil { return err } - } else { - // Ensure product_images table is created even if products table already exists. - if err := db.AutoMigrate(&domain.ProductImage{}); err != nil { - return err - } } + // product_images is managed via raw SQL so it is always created idempotently + // regardless of whether the products table already existed. return applySearchIndex(db) } func applySearchIndex(db *gorm.DB) error { statements := []string{ + // product_images — idempotent, safe to run whether the table exists or not + `CREATE TABLE IF NOT EXISTS product_images ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + url TEXT NOT NULL, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_product_images_product_id ON product_images(product_id)`, + + // full-text search vector `ALTER TABLE products ADD COLUMN IF NOT EXISTS search_vector tsvector`, `CREATE INDEX IF NOT EXISTS idx_products_search ON products USING GIN(search_vector)`, `CREATE OR REPLACE FUNCTION products_search_vector_trigger() RETURNS trigger AS $$ From 71db8b51946a2b2872ba614ff0e918e81f6ec27b Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Sat, 30 May 2026 13:27:48 +0700 Subject: [PATCH 3/3] chore: add GCP service account keys to .gitignore --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 43da9fc..aaadf8d 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,8 @@ coverage/ tmp/ temp/ *.tmp + +# ============================================================ +# GCP SERVICE ACCOUNT KEYS +# ============================================================ +auron-ecommerce-gcp-*.json \ No newline at end of file