diff --git a/delivery/controller/device_details.go b/delivery/controller/device_details.go index 9962cb5..b4de611 100644 --- a/delivery/controller/device_details.go +++ b/delivery/controller/device_details.go @@ -5,7 +5,10 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "strconv" + "strings" "users_management/m/middleware" "users_management/m/model/dto/req" "users_management/m/usecase" @@ -235,13 +238,97 @@ func (c *DeviceDetailsController) updateDeviceDetails(ctx *gin.Context) { return } - var request req.UpdateDeviceDetailsDTO - if err := ctx.ShouldBindJSON(&request); err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + contentType := ctx.GetHeader("Content-Type") + + // Handle JSON request (no images) + if strings.Contains(contentType, "application/json") { + var request req.UpdateDeviceDetailsDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err = c.deviceDetailsUC.UpdateDeviceDetails(deviceID, request) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "Device details updated successfully", nil) return } - err = c.deviceDetailsUC.UpdateDeviceDetails(deviceID, request) + // Handle multipart form request + err = ctx.Request.ParseMultipartForm(50 << 20) // 50MB for multiple images + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Failed to parse multipart form") + return + } + + // Create update DTO from form data + deviceUpdateDTO := req.UpdateDeviceDetailsDTO{} + + if deviceCode := ctx.PostForm("device_code"); deviceCode != "" { + deviceUpdateDTO.DeviceCode = &deviceCode + } + if deviceType := ctx.PostForm("device_type"); deviceType != "" { + deviceUpdateDTO.DeviceType = &deviceType + } + if longitudeStr := ctx.PostForm("longitude"); longitudeStr != "" { + if longitude, err := strconv.ParseFloat(longitudeStr, 64); err == nil { + deviceUpdateDTO.Longitude = &longitude + } else { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid longitude format") + return + } + } + if latitudeStr := ctx.PostForm("latitude"); latitudeStr != "" { + if latitude, err := strconv.ParseFloat(latitudeStr, 64); err == nil { + deviceUpdateDTO.Latitude = &latitude + } else { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid latitude format") + return + } + } + if portAmountStr := ctx.PostForm("port_amount"); portAmountStr != "" { + if portAmount, err := strconv.Atoi(portAmountStr); err == nil { + deviceUpdateDTO.PortAmount = &portAmount + } else { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid port amount format") + return + } + } + if status := ctx.PostForm("status"); status != "" { + deviceUpdateDTO.Status = &status + } + if region := ctx.PostForm("region"); region != "" { + deviceUpdateDTO.Region = ®ion + } + if province := ctx.PostForm("province"); province != "" { + deviceUpdateDTO.Province = &province + } + if city := ctx.PostForm("city"); city != "" { + deviceUpdateDTO.City = &city + } + if district := ctx.PostForm("district"); district != "" { + deviceUpdateDTO.District = &district + } + + // Get multiple image files + form := ctx.Request.MultipartForm + imageFiles := form.File["images"] // Support multiple images + + // Also support single image upload for backward compatibility + if len(imageFiles) == 0 { + if singleImage, err := ctx.FormFile("image"); err == nil { + imageFiles = []*multipart.FileHeader{singleImage} + } + } + + // Handle replace_images flag + replaceImages := ctx.PostForm("replace_images") == "true" + + err = c.deviceDetailsUC.UpdateDeviceDetailsWithMultipleImages(deviceID, deviceUpdateDTO, imageFiles, replaceImages) if err != nil { common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) return diff --git a/delivery/controller/devices_controller.go b/delivery/controller/devices_controller.go index 8d4860a..0065e74 100644 --- a/delivery/controller/devices_controller.go +++ b/delivery/controller/devices_controller.go @@ -1,7 +1,12 @@ package controller import ( + "encoding/json" + "fmt" + "mime/multipart" "net/http" + "strconv" + "strings" "users_management/m/middleware" "users_management/m/model/dto/req" "users_management/m/usecase" @@ -24,6 +29,8 @@ func (dc *DeviceController) Route() { rg.GET("", dc.GetAllDevices()) rg.GET("/:uuid", dc.GetDeviceByID()) rg.PUT("/:uuid", dc.UpdateDevice()) + + rg.POST("/bulk-upload-images", dc.BulkUploadImages()) } } @@ -34,27 +41,202 @@ func NewDeviceController(du usecase.DeviceUseCase, rg *gin.RouterGroup) *DeviceC } } -func (dc *DeviceController) CreateDevice() gin.HandlerFunc { - return func(c *gin.Context) { - var deviceDTO req.DeviceDTO - err := c.ShouldBindJSON(&deviceDTO) - - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } - - err = dc.du.CreateDevice(deviceDTO) - - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } - - common.SingleResponses(c, "Device has been created", nil) - } +func (dc *DeviceController) BulkUploadImages() gin.HandlerFunc { + return func(c *gin.Context) { + // Parse multipart form + err := c.Request.ParseMultipartForm(100 << 20) // 100MB for bulk upload + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Failed to parse multipart form") + return + } + + // Get device data from form + deviceDataStr := c.PostForm("devices") + if deviceDataStr == "" { + common.ErrorResponses(c, http.StatusBadRequest, "devices data is required") + return + } + + // Parse device data + var devicesData []req.BulkDeviceImageUploadDTO + if err := json.Unmarshal([]byte(deviceDataStr), &devicesData); err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid devices data format") + return + } + + // Get all image files + form := c.Request.MultipartForm + allFiles := form.File["images"] + + if len(allFiles) == 0 { + common.ErrorResponses(c, http.StatusBadRequest, "No image files provided") + return + } + + // Parse file distribution from form data + fileDistributionStr := c.PostForm("file_distribution") + var fileDistribution []int + + if fileDistributionStr != "" { + if err := json.Unmarshal([]byte(fileDistributionStr), &fileDistribution); err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid file_distribution format") + return + } + } else { + // Default: distribute files evenly + filesPerDevice := len(allFiles) / len(devicesData) + remainder := len(allFiles) % len(devicesData) + + fileDistribution = make([]int, len(devicesData)) + for i := range fileDistribution { + fileDistribution[i] = filesPerDevice + if i < remainder { + fileDistribution[i]++ + } + } + } + + // Validate file distribution + if len(fileDistribution) != len(devicesData) { + common.ErrorResponses(c, http.StatusBadRequest, + fmt.Sprintf("File distribution count (%d) must match device count (%d)", + len(fileDistribution), len(devicesData))) + return + } + + totalExpectedFiles := 0 + for _, count := range fileDistribution { + totalExpectedFiles += count + } + if totalExpectedFiles != len(allFiles) { + common.ErrorResponses(c, http.StatusBadRequest, + fmt.Sprintf("Total files (%d) must match file distribution sum (%d)", + len(allFiles), totalExpectedFiles)) + return + } + + // Call use case + err = dc.du.BulkUploadImagesMultiple(devicesData, allFiles, fileDistribution) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } + + totalImages := len(allFiles) + common.SingleResponses(c, fmt.Sprintf("%d images uploaded successfully for %d devices", totalImages, len(devicesData)), nil) + } } +func (dc *DeviceController) CreateDevice() gin.HandlerFunc { + return func(c *gin.Context) { + contentType := c.GetHeader("Content-Type") + + // Handle JSON request (no images) + if strings.Contains(contentType, "application/json") { + var deviceDTO req.DeviceDTO + err := c.ShouldBindJSON(&deviceDTO) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } + err = dc.du.CreateDevice(deviceDTO) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(c, "Device has been created", nil) + return + } + + // Parse multipart form + err := c.Request.ParseMultipartForm(50 << 20) // 50MB for multiple images + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Failed to parse multipart form") + return + } + + // Extract form data for device + deviceCode := c.PostForm("device_code") + deviceType := c.PostForm("device_type") + longitudeStr := c.PostForm("longitude") + latitudeStr := c.PostForm("latitude") + portAmountStr := c.PostForm("port_amount") + status := c.PostForm("status") + province := c.PostForm("province") + city := c.PostForm("city") + district := c.PostForm("district") + + // Validate required fields + if deviceCode == "" || deviceType == "" || longitudeStr == "" || latitudeStr == "" || status == "" { + common.ErrorResponses(c, http.StatusBadRequest, "Missing required fields") + return + } + + // Parse coordinates + longitude, err := strconv.ParseFloat(longitudeStr, 64) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid longitude") + return + } + + latitude, err := strconv.ParseFloat(latitudeStr, 64) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid latitude") + return + } + + // Parse port amount + portAmount := 0 + if portAmountStr != "" { + portAmount, err = strconv.Atoi(portAmountStr) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid port amount") + return + } + } + + // Create DTO + deviceDTO := req.DeviceDTO{ + DeviceCode: deviceCode, + DeviceType: deviceType, + Longitude: longitude, + Latitude: latitude, + PortAmount: portAmount, + Status: status, + } + + // Handle optional string fields + if province != "" { + deviceDTO.Province = &province + } + if city != "" { + deviceDTO.City = &city + } + if district != "" { + deviceDTO.District = &district + } + + // Get multiple image files + form := c.Request.MultipartForm + imageFiles := form.File["images"] // Multiple images + + // Also support single image upload for backward compatibility + if len(imageFiles) == 0 { + if singleImage, err := c.FormFile("image"); err == nil { + imageFiles = []*multipart.FileHeader{singleImage} + } + } + + err = dc.du.CreateDeviceWithMultipleImages(deviceDTO, imageFiles) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(c, "Device has been created", nil) + } +} func (dc *DeviceController) GetAllDevices() gin.HandlerFunc { return func(c *gin.Context) { @@ -99,29 +281,108 @@ func (dc *DeviceController) GetDeviceByID() gin.HandlerFunc { } func (dc *DeviceController) UpdateDevice() gin.HandlerFunc { - return func(c *gin.Context) { - id := c.Param("uuid") - uuid, err := uuid.Parse(id) - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, "Invalid UUID") - return - } + return func(c *gin.Context) { + id := c.Param("uuid") + uuid, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid UUID") + return + } - var deviceDTO req.UpdateDeviceDTO - err = c.ShouldBindJSON(&deviceDTO) + contentType := c.GetHeader("Content-Type") + + // Handle JSON request (no images) + if strings.Contains(contentType, "application/json") { + var deviceDTO req.UpdateDeviceDTO + err = c.ShouldBindJSON(&deviceDTO) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid request") + return + } - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, "Invalid request") - return - } + err = dc.du.UpdateDevice(uuid, deviceDTO) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Device not found") + return + } - err = dc.du.UpdateDevice(uuid, deviceDTO) + common.SingleResponses(c, "Device has been updated", nil) + return + } - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, "Device not found") - return - } + // Handle multipart form request + err = c.Request.ParseMultipartForm(50 << 20) // 50MB for multiple images + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Failed to parse multipart form") + return + } - common.SingleResponses(c, "Device has been updated", nil) - } + // Create update DTO from form data + deviceUpdateDTO := req.UpdateDeviceDTO{} + + if deviceCode := c.PostForm("device_code"); deviceCode != "" { + deviceUpdateDTO.DeviceCode = &deviceCode + } + if deviceType := c.PostForm("device_type"); deviceType != "" { + deviceUpdateDTO.DeviceType = &deviceType + } + if longitudeStr := c.PostForm("longitude"); longitudeStr != "" { + if longitude, err := strconv.ParseFloat(longitudeStr, 64); err == nil { + deviceUpdateDTO.Longitude = &longitude + } else { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid longitude format") + return + } + } + if latitudeStr := c.PostForm("latitude"); latitudeStr != "" { + if latitude, err := strconv.ParseFloat(latitudeStr, 64); err == nil { + deviceUpdateDTO.Latitude = &latitude + } else { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid latitude format") + return + } + } + if portAmountStr := c.PostForm("port_amount"); portAmountStr != "" { + if portAmount, err := strconv.Atoi(portAmountStr); err == nil { + deviceUpdateDTO.PortAmount = &portAmount + } else { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid port amount format") + return + } + } + if status := c.PostForm("status"); status != "" { + deviceUpdateDTO.Status = &status + } + if province := c.PostForm("province"); province != "" { + deviceUpdateDTO.Province = &province + } + if city := c.PostForm("city"); city != "" { + deviceUpdateDTO.City = &city + } + if district := c.PostForm("district"); district != "" { + deviceUpdateDTO.District = &district + } + + // Get multiple image files + form := c.Request.MultipartForm + imageFiles := form.File["images"] // Support multiple images + + // Also support single image upload for backward compatibility + if len(imageFiles) == 0 { + if singleImage, err := c.FormFile("image"); err == nil { + imageFiles = []*multipart.FileHeader{singleImage} + } + } + + // Handle replace_images flag + replaceImages := c.PostForm("replace_images") == "true" + + err = dc.du.UpdateDeviceWithMultipleImages(uuid, deviceUpdateDTO, imageFiles, replaceImages) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(c, "Device has been updated", nil) + } } \ No newline at end of file diff --git a/delivery/controller/tower_controller.go b/delivery/controller/tower_controller.go index b787093..bbe5d88 100644 --- a/delivery/controller/tower_controller.go +++ b/delivery/controller/tower_controller.go @@ -1,6 +1,9 @@ package controller import ( + "encoding/json" + "fmt" + "mime/multipart" "net/http" "strconv" "strings" @@ -27,6 +30,8 @@ func (tc *TowerController) Route() { rg.GET("/:uuid", tc.GetTowerByID()) rg.PUT("/:uuid", tc.UpdateTower()) + + rg.POST("/bulk-upload-images", tc.BulkUploadImages()) } } @@ -37,6 +42,56 @@ func NewTowerController(tu usecase.TowerUseCase, rg *gin.RouterGroup) *TowerCont } } +func (tc *TowerController) BulkUploadImages() gin.HandlerFunc { + return func(c *gin.Context) { + // Parse multipart form + err := c.Request.ParseMultipartForm(50 << 20) // 50MB max for bulk + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Failed to parse multipart form") + return + } + + // Get tower data from form + towerDataStr := c.PostForm("towers") + if towerDataStr == "" { + common.ErrorResponses(c, http.StatusBadRequest, "towers data is required") + return + } + + // Parse tower data + var towersData []req.BulkTowerImageUploadDTO + if err := json.Unmarshal([]byte(towerDataStr), &towersData); err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid towers data format") + return + } + + // Get image files + form := c.Request.MultipartForm + files := form.File["images"] + + if len(files) == 0 { + common.ErrorResponses(c, http.StatusBadRequest, "No image files provided") + return + } + + if len(files) != len(towersData) { + common.ErrorResponses(c, http.StatusBadRequest, + fmt.Sprintf("Number of images (%d) must match number of towers (%d)", + len(files), len(towersData))) + return + } + + // Call use case + err = tc.tu.BulkUploadImages(towersData, files) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(c, fmt.Sprintf("%d tower images uploaded successfully", len(towersData)), nil) + } +} + func (tc *TowerController) GetTower() gin.HandlerFunc { return func(c *gin.Context) { towers, err := tc.tu.GetAll() @@ -55,7 +110,7 @@ func (tc *TowerController) CreateTower() gin.HandlerFunc { contentType := c.GetHeader("Content-Type") - // Handle JSON request + // Handle JSON request (no images) if strings.Contains(contentType, "application/json") { var towerDTO req.TowerDTO if err := c.ShouldBindJSON(&towerDTO); err != nil { @@ -63,7 +118,7 @@ func (tc *TowerController) CreateTower() gin.HandlerFunc { return } - // No image file for JSON requests + // No image files for JSON requests err := tc.tu.Post(towerDTO, nil) if err != nil { common.ErrorResponses(c, http.StatusBadRequest, err.Error()) @@ -73,15 +128,14 @@ func (tc *TowerController) CreateTower() gin.HandlerFunc { common.SingleResponses(c, "Tower has been created", nil) return } + // Parse multipart form - err := c.Request.ParseMultipartForm(10 << 20) // 10MB max + err := c.Request.ParseMultipartForm(50 << 20) // Increase to 50MB for multiple images if err != nil { common.ErrorResponses(c, http.StatusBadRequest, "Failed to parse multipart form") return } - - // Extract form data deviceIDStr := c.PostForm("dev_id") towerCode := c.PostForm("tower_code") @@ -137,10 +191,18 @@ func (tc *TowerController) CreateTower() gin.HandlerFunc { ExternalTower: externalTower, } - // Get image file (optional) - imageFile, _ := c.FormFile("image") + // Get multiple image files + form := c.Request.MultipartForm + imageFiles := form.File["images"] // Changed from "image" to "images" to support multiple + + // Also support single image upload for backward compatibility + if len(imageFiles) == 0 { + if singleImage, err := c.FormFile("image"); err == nil { + imageFiles = []*multipart.FileHeader{singleImage} + } + } - err = tc.tu.Post(towerDTO, imageFile) + err = tc.tu.PostWithMultipleImages(towerDTO, imageFiles) if err != nil { common.ErrorResponses(c, http.StatusBadRequest, err.Error()) return @@ -181,7 +243,7 @@ func (tc *TowerController) UpdateTower() gin.HandlerFunc { contentType := c.GetHeader("Content-Type") - // Handle JSON request + // Handle JSON request (no images) if strings.Contains(contentType, "application/json") { var towerUpdateDTO req.UpdateTowerDTO if err := c.ShouldBindJSON(&towerUpdateDTO); err != nil { @@ -189,8 +251,8 @@ func (tc *TowerController) UpdateTower() gin.HandlerFunc { return } - // No image file for JSON requests - err := tc.tu.UpdateTower(tower_uuid, towerUpdateDTO, nil) + // No image files for JSON requests + err := tc.tu.UpdateTowerWithMultipleImages(tower_uuid, towerUpdateDTO, nil) if err != nil { common.ErrorResponses(c, http.StatusBadRequest, err.Error()) return @@ -200,8 +262,8 @@ func (tc *TowerController) UpdateTower() gin.HandlerFunc { return } - // Handle multipart form request (existing logic) - err = c.Request.ParseMultipartForm(10 << 20) // 10MB max + // Handle multipart form request + err = c.Request.ParseMultipartForm(50 << 20) // Increase to 50MB for multiple images if err != nil { common.ErrorResponses(c, http.StatusBadRequest, "Failed to parse multipart form") return @@ -257,10 +319,21 @@ func (tc *TowerController) UpdateTower() gin.HandlerFunc { towerUpdateDTO.ImageURL = &imageURL } - // Get image file (optional) - imageFile, _ := c.FormFile("image") + // Get multiple image files + form := c.Request.MultipartForm + imageFiles := form.File["images"] // Support multiple images + + // Also support single image upload for backward compatibility + if len(imageFiles) == 0 { + if singleImage, err := c.FormFile("image"); err == nil { + imageFiles = []*multipart.FileHeader{singleImage} + } + } - err = tc.tu.UpdateTower(tower_uuid, towerUpdateDTO, imageFile) + // Handle replace_images flag - if true, replace all images; if false, append to existing + replaceImages := c.PostForm("replace_images") == "true" + + err = tc.tu.UpdateTowerWithMultipleImages(tower_uuid, towerUpdateDTO, imageFiles, replaceImages) if err != nil { common.ErrorResponses(c, http.StatusBadRequest, err.Error()) return diff --git a/delivery/server.go b/delivery/server.go index 129258d..8e1e496 100644 --- a/delivery/server.go +++ b/delivery/server.go @@ -50,6 +50,7 @@ func (s *Server) setupController() { rg := s.engine.Group("/api/v1") controller.NewUsersController(s.ucManager.NewUserUsecase(), s.ucManager.NewAuthUsecase(),rg).Route() rg.Use(middleware.AuthMiddleware(s.ucManager.NewUserUsecase())) + controller.NewCountAssetsController(s.ucManager.NewCountAssetsUsecase(), rg).Route() rg.Use(middleware.ActivityLoggingMiddleware(s.ucManager.NewActivityLogUsecase())) { controller.NewDeviceController(s.ucManager.NewDeviceUsecase(), rg).Route() @@ -57,7 +58,6 @@ func (s *Server) setupController() { controller.NewFishboneController(s.ucManager.NewFishboneUsecase(), rg).Route() controller.NewTowerController(s.ucManager.NewTowerUsecase(), rg).Route() controller.NewDevicePortController(s.ucManager.NewDevicePortUsecase(), rg).Route() - controller.NewCountAssetsController(s.ucManager.NewCountAssetsUsecase(), rg).Route() controller.NewActivityLogController(s.ucManager.NewActivityLogUsecase(), rg).Route() controller.NewDeviceInspectionController(s.ucManager.NewDeviceInspectionUsecase(), rg).Route() controller.NewNearestDeviceController(s.ucManager.NewNearestDeviceUsecase(), rg).Route() diff --git a/model/dto/req/deviceDetails.go b/model/dto/req/deviceDetails.go index ee30eec..dd8f521 100644 --- a/model/dto/req/deviceDetails.go +++ b/model/dto/req/deviceDetails.go @@ -25,6 +25,7 @@ type UpdateDeviceDetailsDTO struct { Province *string `json:"province,omitempty" validate:"omitempty,min=3"` City *string `json:"city,omitempty" validate:"omitempty,min=3"` District *string `json:"district,omitempty" validate:"omitempty,min=3"` + } type AssignMultipleCustomersDTO struct { diff --git a/model/dto/req/device_dto.go b/model/dto/req/device_dto.go index cc35165..72202ea 100644 --- a/model/dto/req/device_dto.go +++ b/model/dto/req/device_dto.go @@ -1,25 +1,35 @@ package req +import "github.com/google/uuid" + type DeviceDTO struct { - DeviceCode string `json:"device_code" validate:"required"` - DeviceType string `json:"device_type" validate:"required"` - Longitude float64 `json:"longitude" validate:"required"` - Latitude float64 `json:"latitude" validate:"required"` - PortAmount int `json:"port_amount"` - Status string `json:"status" validate:"required,oneof=active inactive maintenance"` - Province *string `json:"province,omitempty" validate:"omitempty,min=3"` - City *string `json:"city,omitempty" validate:"omitempty,min=3"` - District *string `json:"district,omitempty" validate:"omitempty,min=3"` + DeviceCode string `json:"device_code" validate:"required"` + DeviceType string `json:"device_type" validate:"required"` + Longitude float64 `json:"longitude" validate:"required"` + Latitude float64 `json:"latitude" validate:"required"` + PortAmount int `json:"port_amount"` + Status string `json:"status" validate:"required,oneof=active inactive maintenance"` + Province *string `json:"province,omitempty" validate:"omitempty,min=3"` + City *string `json:"city,omitempty" validate:"omitempty,min=3"` + District *string `json:"district,omitempty" validate:"omitempty,min=3"` } type UpdateDeviceDTO struct { - DeviceCode *string `json:"device_code,omitempty" validate:"omitempty,min=3"` - DeviceType *string `json:"device_type,omitempty" validate:"omitempty,min=3"` - Longitude *float64 `json:"longitude,omitempty" validate:"omitempty,longitude"` - Latitude *float64 `json:"latitude,omitempty" validate:"omitempty,latitude"` - PortAmount *int `json:"port_amount,omitempty" validate:"omitempty"` - Status *string `json:"status,omitempty" validate:"omitempty,oneof=active inactive maintenance"` - Province *string `json:"province,omitempty" validate:"omitempty,min=3"` - City *string `json:"city,omitempty" validate:"omitempty,min=3"` - District *string `json:"district,omitempty" validate:"omitempty,min=3"` + DeviceCode *string `json:"device_code,omitempty" validate:"omitempty,min=3"` + DeviceType *string `json:"device_type,omitempty" validate:"omitempty,min=3"` + Longitude *float64 `json:"longitude,omitempty" validate:"omitempty,longitude"` + Latitude *float64 `json:"latitude,omitempty" validate:"omitempty,latitude"` + PortAmount *int `json:"port_amount,omitempty" validate:"omitempty"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=active inactive maintenance"` + Province *string `json:"province,omitempty" validate:"omitempty,min=3"` + City *string `json:"city,omitempty" validate:"omitempty,min=3"` + District *string `json:"district,omitempty" validate:"omitempty,min=3"` +} + +type BulkDeviceImageUploadDTO struct { + DeviceID uuid.UUID `json:"device_id" validate:"required"` +} + +type BulkDeviceImagesDTO struct { + Devices []BulkDeviceImageUploadDTO `json:"devices" validate:"required,min=1"` } \ No newline at end of file diff --git a/model/dto/req/tower_dto.go b/model/dto/req/tower_dto.go index 8dab41c..fd05c66 100644 --- a/model/dto/req/tower_dto.go +++ b/model/dto/req/tower_dto.go @@ -19,4 +19,13 @@ type UpdateTowerDTO struct { Latitude *float64 `json:"latitude,omitempty" validate:"omitempty,latitude"` ExternalTower *bool `json:"external_tower,omitempty"` // Make nullable ImageURL *string `json:"image_url,omitempty"` +} + +// Add to model/dto/req/tower_dto.go +type BulkTowerImageUploadDTO struct { + TowerID uuid.UUID `json:"tower_id" validate:"required"` +} + +type BulkTowerImagesDTO struct { + Towers []BulkTowerImageUploadDTO `json:"towers" validate:"required,min=1"` } \ No newline at end of file diff --git a/model/dto/res/device_details.go b/model/dto/res/device_details.go index 12de463..c444e3e 100644 --- a/model/dto/res/device_details.go +++ b/model/dto/res/device_details.go @@ -23,6 +23,7 @@ type DeviceDetailsResponse struct { City *string `json:"city,omitempty"` District *string `json:"district,omitempty"` ImageURL *string `json:"image_url,omitempty"` + ImageURLs []string `json:"image_urls"` // Connection details Backbones []BackboneConnectionInfo `json:"backbones"` @@ -39,6 +40,7 @@ type TowerConnectionDetail struct { Distance float64 `json:"distance_km"` // Distance from tower to device ExternalTower *bool `json:"external_tower"` // Indicates if this is an external tower ImageURL *string `json:"image_url,omitempty"` + ImageURLs []string `json:"image_urls"` // Store multiple images as JSONB } type PortAssignmentResponse struct { diff --git a/model/dto/res/device_res.go b/model/dto/res/device_res.go index d7ff642..0361989 100644 --- a/model/dto/res/device_res.go +++ b/model/dto/res/device_res.go @@ -12,13 +12,15 @@ type DeviceResponse struct { DeviceType string `json:"device_type"` Longitude float64 `json:"longitude"` Latitude float64 `json:"latitude"` - Address string `json:"address"` PortAmount int `json:"port_amount"` - Status string `json:"status"` // Always include, even if null - CustomerNames []string `json:"customer_names"` // Always include, even if empty - Province *string `json:"province"` // Always include, even if null - City *string `json:"city"` // Always include, even if null - District *string `json:"district"` // Always include, even if null + Status string `json:"status"` + Address string `json:"address"` + Region *string `json:"region"` + Province *string `json:"province"` + City *string `json:"city"` + District *string `json:"district"` + ImageURL *string `json:"image_url"` // Primary image + ImageURLs []string `json:"image_urls"` // All images CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/model/dto/res/tower_res.go b/model/dto/res/tower_res.go index 61aeff6..890d28a 100644 --- a/model/dto/res/tower_res.go +++ b/model/dto/res/tower_res.go @@ -13,6 +13,7 @@ type TowerResponse struct { Latitude float64 `json:"latitude"` Address string `json:"address"` ImageURL string `json:"image_url"` + ImageURLs []string `json:"image_urls"` // Store multiple images as JSONB ExternalTower *bool `json:"external_tower"` // Make nullable CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/model/entity/device_details.go b/model/entity/device_details.go index c5e6e3d..318c1de 100644 --- a/model/entity/device_details.go +++ b/model/entity/device_details.go @@ -18,6 +18,7 @@ type DeviceDetails struct { City *string `json:"city,omitempty" gorm:"type:varchar(255)"` District *string `json:"district,omitempty" gorm:"type:varchar(255)"` ImageURL *string `json:"image_url,omitempty" gorm:"type:text"` + AdditionalImages StringSlice `gorm:"type:text[]"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -48,4 +49,5 @@ func (d *DeviceDetails) GetAllFishbones() []Fishbone { allFishbones = append(allFishbones, d.FishbonesStart...) allFishbones = append(allFishbones, d.FishbonesEnd...) return allFishbones -} \ No newline at end of file +} + diff --git a/model/entity/device_port.go b/model/entity/device_port.go index 0e99684..fd66c38 100644 --- a/model/entity/device_port.go +++ b/model/entity/device_port.go @@ -72,13 +72,14 @@ func (p *PortAssignments) Scan(value interface{}) error { var bytes []byte switch v := value.(type) { - case []byte: // []byte and []uint8 are the same type in Go + case []byte: bytes = v case string: bytes = []byte(v) default: return fmt.Errorf("cannot scan %T into PortAssignments", value) } + if len(bytes) == 0 { *p = PortAssignments{} return nil diff --git a/model/entity/devices.go b/model/entity/devices.go index 8c45be8..28a4755 100644 --- a/model/entity/devices.go +++ b/model/entity/devices.go @@ -31,10 +31,43 @@ type Device struct { City *string `json:"city,omitempty" gorm:"type:varchar(255)"` District *string `json:"district,omitempty" gorm:"type:varchar(255)"` ImageURL *string `json:"image_url,omitempty" gorm:"type:text"` + ImageURLs StringSlice `json:"image_urls" gorm:"type:jsonb"` // Store multiple images as JSONB CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +func (d *Device) GetAllImageURLs() []string { + var allImages []string + + // Add main image URL if exists + if d.ImageURL != nil && *d.ImageURL != "" { + allImages = append(allImages, *d.ImageURL) + } + + // Add additional images + for _, img := range d.ImageURLs { + if img != "" && (d.ImageURL == nil || img != *d.ImageURL) { // Avoid duplicates + allImages = append(allImages, img) + } + } + + return allImages +} + +// Set multiple images (first one becomes primary) +func (d *Device) SetMultipleImages(imageURLs []string) { + if len(imageURLs) == 0 { + return + } + + // Set primary image + primaryImage := imageURLs[0] + d.ImageURL = &primaryImage + + // Set all images + d.ImageURLs = StringSlice(imageURLs) +} + func (Device) TableName() string { return "devices" } \ No newline at end of file diff --git a/model/entity/tower.go b/model/entity/tower.go index 9a59dc5..07424f6 100644 --- a/model/entity/tower.go +++ b/model/entity/tower.go @@ -1,3 +1,4 @@ +// Update model/entity/tower.go package entity import ( @@ -11,12 +12,46 @@ type Tower struct { TowerCode string `json:"tower_code" gorm:"unique"` Longitude float64 `json:"longitude"` Latitude float64 `json:"latitude"` - ImageURL string `json:"image_url" gorm:"column:image_url"` - ExternalTower *bool `json:"external_tower,omitempty" gorm:"column:external_tower;null"` // Make nullable and fix typo - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ImageURL string `json:"image_url"` // Keep for backward compatibility + ImageURLs StringSlice `json:"image_urls" gorm:"type:jsonb"` // Multiple images + ExternalTower *bool `json:"external_tower,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relationships + Device Device `json:"device,omitempty" gorm:"foreignKey:DeviceID"` +} - Device *Device `json:"device,omitempty" gorm:"foreignKey:DeviceID"` // Make nullable +// Helper method to get all image URLs +func (t *Tower) GetAllImageURLs() []string { + var allImages []string + + // Add main image URL if exists + if t.ImageURL != "" { + allImages = append(allImages, t.ImageURL) + } + + // Add additional images + for _, img := range t.ImageURLs { + if img != "" && img != t.ImageURL { // Avoid duplicates + allImages = append(allImages, img) + } + } + + return allImages +} + +// Set multiple images (first one becomes primary) +func (t *Tower) SetMultipleImages(imageURLs []string) { + if len(imageURLs) == 0 { + return + } + + // Set primary image + t.ImageURL = imageURLs[0] + + // Set all images + t.ImageURLs = StringSlice(imageURLs) } func (Tower) TableName() string { diff --git a/repository/devices_repo.go b/repository/devices_repo.go index e62d9e1..ce10b77 100644 --- a/repository/devices_repo.go +++ b/repository/devices_repo.go @@ -1,6 +1,7 @@ package repository import ( + "fmt" "time" "users_management/m/model/entity" @@ -15,6 +16,8 @@ type DevicesRepo interface { GetByID(id uuid.UUID) (entity.Device, error) GetByType(deviceType string) ([]entity.Device, error) + BulkUpdateImages(updates map[uuid.UUID]string) error + BulkUpdateImagesMultiple(updates map[uuid.UUID][]string) error } type devicesRepo struct { @@ -27,6 +30,40 @@ func NewDevicesRepo(db *gorm.DB) DevicesRepo { } } +func (r *devicesRepo) BulkUpdateImages(updates map[uuid.UUID]string) error { + multipleUpdates := make(map[uuid.UUID][]string) + for deviceID, imageURL := range updates { + multipleUpdates[deviceID] = []string{imageURL} + } + return r.BulkUpdateImagesMultiple(multipleUpdates) +} + +func (r *devicesRepo) BulkUpdateImagesMultiple(updates map[uuid.UUID][]string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + for deviceID, imageURLs := range updates { + updateFields := map[string]interface{}{ + "updated_at": time.Now(), + } + + if len(imageURLs) > 0 { + // Set primary image (first one) + updateFields["image_url"] = imageURLs[0] + + // Set all images using StringSlice + updateFields["image_urls"] = entity.StringSlice(imageURLs) + } + + if err := tx.Model(&entity.Device{}). + Where("id = ?", deviceID). + Updates(updateFields).Error; err != nil { + return fmt.Errorf("failed to update device %s: %w", deviceID, err) + } + } + return nil + }) +} + + func (r *devicesRepo) Post(device entity.Device) error { return r.db.Transaction(func(tx *gorm.DB) error { // Create the device first diff --git a/repository/tower_repo.go b/repository/tower_repo.go index ea798f1..2da4470 100644 --- a/repository/tower_repo.go +++ b/repository/tower_repo.go @@ -1,6 +1,8 @@ package repository import ( + "fmt" + "time" "users_management/m/model/entity" "github.com/google/uuid" @@ -13,6 +15,7 @@ type TowerRepo interface { Update(id uuid.UUID,updates map[string]interface{}) error GetByID(id uuid.UUID) (entity.Tower, error) CheckDeviceExists(deviceID uuid.UUID) (bool, error) + BulkUpdateImages(updates map[uuid.UUID]string) error } type towerRepo struct { @@ -63,4 +66,20 @@ func (r *towerRepo) GetByID(id uuid.UUID) (entity.Tower, error) { return tower, err } return tower, nil +} + +func (r *towerRepo) BulkUpdateImages(updates map[uuid.UUID]string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + for towerID, imageURL := range updates { + if err := tx.Model(&entity.Tower{}). + Where("id = ?", towerID). + Updates(map[string]interface{}{ + "image_url": imageURL, + "updated_at": time.Now(), + }).Error; err != nil { + return fmt.Errorf("failed to update tower %s: %w", towerID, err) + } + } + return nil + }) } \ No newline at end of file diff --git a/usecase/device_details.go b/usecase/device_details.go index 37b3809..b5e5e69 100644 --- a/usecase/device_details.go +++ b/usecase/device_details.go @@ -3,6 +3,7 @@ package usecase import ( "errors" "fmt" + "mime/multipart" "time" "users_management/m/model/dto/req" "users_management/m/model/dto/res" @@ -32,6 +33,7 @@ type DeviceDetailsUseCase interface { UpdateCustomerByPort(deviceID uuid.UUID, update req.UpdateCustomerByPortDTO) error BulkUpdateCustomersByPort(deviceID uuid.UUID, updates []req.UpdateCustomerByPortDTO) error RemoveCustomerByPort(deviceID uuid.UUID, portNumber int) error + UpdateDeviceDetailsWithMultipleImages(id uuid.UUID, deviceDTO req.UpdateDeviceDetailsDTO, imageFiles []*multipart.FileHeader, replaceImages ...bool) error } type deviceDetailsUseCase struct { @@ -48,6 +50,122 @@ func NewDeviceDetailsUseCase(deviceDetailsRepo repository.DeviceDetailsRepo, geo } } +func (u *deviceDetailsUseCase) UpdateDeviceDetailsWithMultipleImages(id uuid.UUID, deviceDTO req.UpdateDeviceDetailsDTO, imageFiles []*multipart.FileHeader, replaceImages ...bool) error { + err := u.validate.Struct(deviceDTO) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + updates := map[string]interface{}{} + + if deviceDTO.DeviceCode != nil { + updates["device_code"] = *deviceDTO.DeviceCode + } + if deviceDTO.DeviceType != nil { + updates["device_type"] = *deviceDTO.DeviceType + } + if deviceDTO.Longitude != nil { + updates["longitude"] = *deviceDTO.Longitude + } + if deviceDTO.Latitude != nil { + updates["latitude"] = *deviceDTO.Latitude + } + if deviceDTO.PortAmount != nil { + // Validate port amount change + if *deviceDTO.PortAmount < 0 { + return fmt.Errorf("port amount cannot be negative") + } + + // If not setting to 0, check current usage + if *deviceDTO.PortAmount > 0 { + currentUsed, _, err := u.deviceDetailsRepo.GetPortUsageByDevice(id) + if err != nil { + return err + } + if *deviceDTO.PortAmount < currentUsed { + return fmt.Errorf("cannot reduce port amount to %d, currently using %d ports", *deviceDTO.PortAmount, currentUsed) + } + } + + updates["port_amount"] = *deviceDTO.PortAmount + } + if deviceDTO.Status != nil { + updates["status"] = *deviceDTO.Status + } + if deviceDTO.Region != nil { + updates["region"] = *deviceDTO.Region + } + if deviceDTO.Province != nil { + updates["province"] = *deviceDTO.Province + } + if deviceDTO.City != nil { + updates["city"] = *deviceDTO.City + } + if deviceDTO.District != nil { + updates["district"] = *deviceDTO.District + } + + // Handle multiple image uploads + if len(imageFiles) > 0 { + // Get current device to handle existing images + currentDevice, err := u.deviceDetailsRepo.GetByID(id) + if err != nil { + return err + } + + // Save new images + newImageURLs, err := helper.SaveDeviceImagesBulk(imageFiles) + if err != nil { + return err + } + + var finalImageURLs []string + shouldReplace := len(replaceImages) > 0 && replaceImages[0] + + if shouldReplace { + // Replace all images - delete old ones + if currentDevice.ImageURL != nil && *currentDevice.ImageURL != "" { + helper.DeleteDeviceImage(*currentDevice.ImageURL) + } + // Delete other images if they exist + for _, imgURL := range currentDevice.AdditionalImages { + if imgURL != "" && (currentDevice.ImageURL == nil || imgURL != *currentDevice.ImageURL) { + helper.DeleteDeviceImage(imgURL) + } + } + finalImageURLs = newImageURLs + } else { + // Append to existing images + existingImages := make([]string, 0) + if currentDevice.ImageURL != nil && *currentDevice.ImageURL != "" { + existingImages = append(existingImages, *currentDevice.ImageURL) + } + for _, imgURL := range currentDevice.AdditionalImages { + if imgURL != "" && (currentDevice.ImageURL == nil || imgURL != *currentDevice.ImageURL) { + existingImages = append(existingImages, imgURL) + } + } + finalImageURLs = append(existingImages, newImageURLs...) + } + + // Update primary image (first image in the final list) + if len(finalImageURLs) > 0 && finalImageURLs[0] != "" { + updates["image_url"] = finalImageURLs[0] + } + + // Update all images + updates["additional_images"] = entity.StringSlice(finalImageURLs) + } + + if len(updates) == 0 { + return errors.New("no fields to update") + } + + updates["updated_at"] = time.Now() + + return u.deviceDetailsRepo.Update(id, updates) +} + func (u *deviceDetailsUseCase) UpdateCustomerByPort(deviceID uuid.UUID, update req.UpdateCustomerByPortDTO) error { // Validate port number if update.PortNumber < 1 { diff --git a/usecase/device_usecase.go b/usecase/device_usecase.go index 63c7d0c..320ef8f 100644 --- a/usecase/device_usecase.go +++ b/usecase/device_usecase.go @@ -2,6 +2,7 @@ package usecase import ( "fmt" + "mime/multipart" "time" "users_management/m/model/dto/req" "users_management/m/model/dto/res" @@ -21,6 +22,10 @@ type DeviceUseCase interface { GetByID(id uuid.UUID) (res.DeviceResponse, error) UpdateDevice(id uuid.UUID, device req.UpdateDeviceDTO) error GetByType(deviceType string) ([]res.DeviceTypeResponse, error) + BulkUploadImages(devices []req.BulkDeviceImageUploadDTO, imageFiles []*multipart.FileHeader) error + CreateDeviceWithMultipleImages(device req.DeviceDTO, imageFiles []*multipart.FileHeader) error + UpdateDeviceWithMultipleImages(id uuid.UUID, device req.UpdateDeviceDTO, imageFiles []*multipart.FileHeader, replaceImages ...bool) error + BulkUploadImagesMultiple(devices []req.BulkDeviceImageUploadDTO, imageFiles []*multipart.FileHeader, fileDistribution []int) error } type deviceUseCase struct { @@ -37,33 +42,188 @@ func NewDeviceUseCase(deviceRepo repository.DevicesRepo, geocoder service.Geocod } } -func (u *deviceUseCase) CreateDevice(device req.DeviceDTO) error { - err := u.validate.Struct(device) - if err != nil { - return fmt.Errorf("validation error: %w", err) - } - - if device.DeviceType == "OTB" || device.DeviceType == "ODP" && device.PortAmount <= 0 { - return fmt.Errorf("port amount must be greater than 0 for OTB or ODP devices") - } - - newDevice := entity.Device{ - ID: uuid.New(), - DeviceCode: device.DeviceCode, - DeviceType: entity.DeviceType(device.DeviceType), - Longitude: device.Longitude, - Latitude: device.Latitude, - PortAmount: device.PortAmount, - Status: entity.DeviceStatus(device.Status), - Province: device.Province, // Now nullable - City: device.City, // Now nullable - District: device.District, // Now nullable - CreatedAt: time.Now(), - UpdatedAt: time.Now(), +func (u *deviceUseCase) CreateDeviceWithMultipleImages(device req.DeviceDTO, imageFiles []*multipart.FileHeader) error { + err := u.validate.Struct(device) + if err != nil { + return fmt.Errorf("validation error: %w", err) } - - return u.deviceRepo.Post(newDevice) + if device.DeviceType == "OTB" || device.DeviceType == "ODP" && device.PortAmount <= 0 { + return fmt.Errorf("port amount must be greater than 0 for OTB or ODP devices") + } + + var imageURLs []string + var primaryImageURL string + + if len(imageFiles) > 0 { + // Save all images + imageURLs, err = helper.SaveDeviceImagesBulk(imageFiles) + if err != nil { + return err + } + + // First image becomes primary + if len(imageURLs) > 0 && imageURLs[0] != "" { + primaryImageURL = imageURLs[0] + } + } + + newDevice := entity.Device{ + ID: uuid.New(), + DeviceCode: device.DeviceCode, + DeviceType: entity.DeviceType(device.DeviceType), + Longitude: device.Longitude, + Latitude: device.Latitude, + PortAmount: device.PortAmount, + Status: entity.DeviceStatus(device.Status), + Province: device.Province, + City: device.City, + District: device.District, + ImageURL: &primaryImageURL, // Primary image as pointer + ImageURLs: entity.StringSlice(imageURLs), // All images + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + return u.deviceRepo.Post(newDevice) +} + +func (u *deviceUseCase) UpdateDeviceWithMultipleImages(id uuid.UUID, device req.UpdateDeviceDTO, imageFiles []*multipart.FileHeader, replaceImages ...bool) error { + err := u.validate.Struct(device) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + updates := map[string]interface{}{} + + if device.DeviceCode != nil { + updates["DeviceCode"] = *device.DeviceCode + } + if device.DeviceType != nil { + updates["DeviceType"] = *device.DeviceType + } + if device.Longitude != nil { + updates["Longitude"] = *device.Longitude + } + if device.Latitude != nil { + updates["Latitude"] = *device.Latitude + } + if device.PortAmount != nil { + updates["PortAmount"] = *device.PortAmount + } + if device.Status != nil { + updates["Status"] = *device.Status + } + if device.Province != nil { + updates["Province"] = *device.Province + } + if device.City != nil { + updates["City"] = *device.City + } + if device.District != nil { + updates["District"] = *device.District + } + + // Handle multiple image uploads + if len(imageFiles) > 0 { + // Get current device to handle existing images + currentDevice, err := u.deviceRepo.GetByID(id) + if err != nil { + return err + } + + // Save new images + newImageURLs, err := helper.SaveDeviceImagesBulk(imageFiles) + if err != nil { + return err + } + + var finalImageURLs []string + shouldReplace := len(replaceImages) > 0 && replaceImages[0] + + if shouldReplace { + // Replace all images - delete old ones + for _, oldImageURL := range currentDevice.GetAllImageURLs() { + helper.DeleteDeviceImage(oldImageURL) + } + finalImageURLs = newImageURLs + } else { + // Append to existing images + existingImages := currentDevice.GetAllImageURLs() + finalImageURLs = append(existingImages, newImageURLs...) + } + + // Update primary image (first image in the final list) + if len(finalImageURLs) > 0 && finalImageURLs[0] != "" { + updates["ImageURL"] = finalImageURLs[0] + } + + // Update all images + updates["ImageURLs"] = entity.StringSlice(finalImageURLs) + } + + if device.DeviceType != nil && (*device.DeviceType == "OTB" || *device.DeviceType == "ODP") && device.PortAmount != nil && *device.PortAmount <= 0 { + return fmt.Errorf("port amount must be greater than 0 for OTB or ODP devices") + } + + if len(updates) == 0 { + return fmt.Errorf("no update data") + } + + updates["UpdatedAt"] = time.Now() + + return u.deviceRepo.Update(id, updates) +} +func (u *deviceUseCase) BulkUploadImagesMultiple(devices []req.BulkDeviceImageUploadDTO, imageFiles []*multipart.FileHeader, fileDistribution []int) error { + if len(devices) == 0 { + return fmt.Errorf("no devices provided") + } + + if len(fileDistribution) != len(devices) { + return fmt.Errorf("file distribution count must match device count") + } + + // Validate all device IDs exist + for i, device := range devices { + _, err := u.deviceRepo.GetByID(device.DeviceID) + if err != nil { + return fmt.Errorf("device %d with ID %s not found", i, device.DeviceID) + } + } + + // Save all images + imageURLs, err := helper.SaveDeviceImagesBulk(imageFiles) + if err != nil { + return fmt.Errorf("failed to save images: %w", err) + } + + // Distribute images to devices + updates := make(map[uuid.UUID][]string) + fileIndex := 0 + + for i, device := range devices { + imageCount := fileDistribution[i] + deviceImages := make([]string, 0) + + for j := 0; j < imageCount && fileIndex < len(imageURLs); j++ { + if imageURLs[fileIndex] != "" { + deviceImages = append(deviceImages, imageURLs[fileIndex]) + } + fileIndex++ + } + + if len(deviceImages) > 0 { + updates[device.DeviceID] = deviceImages + } + } + + // Update database + return u.deviceRepo.BulkUpdateImagesMultiple(updates) +} + +// Keep old methods for backward compatibility +func (u *deviceUseCase) CreateDevice(device req.DeviceDTO) error { + return u.CreateDeviceWithMultipleImages(device, nil) } func (u *deviceUseCase) GetAllDevices() ([]res.DeviceResponse, error) { @@ -78,6 +238,15 @@ func (u *deviceUseCase) GetAllDevices() ([]res.DeviceResponse, error) { return devicesResponse, nil } +func (u *deviceUseCase) BulkUploadImages(devices []req.BulkDeviceImageUploadDTO, imageFiles []*multipart.FileHeader) error { + // Default to one image per device + fileDistribution := make([]int, len(devices)) + for i := range fileDistribution { + fileDistribution[i] = 1 + } + return u.BulkUploadImagesMultiple(devices, imageFiles, fileDistribution) +} + func (u *deviceUseCase) GetByID(id uuid.UUID) (res.DeviceResponse, error) { device, err := u.deviceRepo.GetByID(id) if err != nil { diff --git a/usecase/tower_usecase.go b/usecase/tower_usecase.go index 3601a22..d231899 100644 --- a/usecase/tower_usecase.go +++ b/usecase/tower_usecase.go @@ -21,6 +21,9 @@ type TowerUseCase interface { GetAll() ([]res.TowerResponse, error) GetByID(id uuid.UUID) (res.TowerResponse, error) UpdateTower(id uuid.UUID, tower req.UpdateTowerDTO, imageFile *multipart.FileHeader) error + BulkUploadImages(towers []req.BulkTowerImageUploadDTO, imageFiles []*multipart.FileHeader) error + PostWithMultipleImages(tower req.TowerDTO, imageFiles []*multipart.FileHeader) error + UpdateTowerWithMultipleImages(id uuid.UUID, tower req.UpdateTowerDTO, imageFiles []*multipart.FileHeader, replaceImages ...bool) error } type towerUsecase struct { @@ -36,6 +39,135 @@ func NewTowerUseCase(towerRepo repository.TowerRepo, geocoder service.GeocodingS validate: validator.New(), } } +func (u *towerUsecase) PostWithMultipleImages(tower req.TowerDTO, imageFiles []*multipart.FileHeader) error { + err := u.validate.Struct(tower) + if err != nil { + return err + } + + // Validate that if it's not an external tower, DeviceID must be provided + if tower.ExternalTower != nil && !*tower.ExternalTower && tower.DeviceID == nil { + return fmt.Errorf("device_id is required for internal towers") + } + + // Validate that if DeviceID is provided, the device exists + if tower.DeviceID != nil { + deviceExists, err := u.towerRepo.CheckDeviceExists(*tower.DeviceID) + if err != nil { + return err + } + if !deviceExists { + return fmt.Errorf("device not found") + } + } + + var imageURLs []string + var primaryImageURL string + + if len(imageFiles) > 0 { + // Save all images + imageURLs, err = helper.SaveTowerImagesBulk(imageFiles) + if err != nil { + return err + } + + // First image becomes primary + if len(imageURLs) > 0 && imageURLs[0] != "" { + primaryImageURL = imageURLs[0] + } + } + + newTower := entity.Tower{ + ID: uuid.New(), + DeviceID: tower.DeviceID, + TowerCode: tower.TowerCode, + Longitude: tower.Longitude, + Latitude: tower.Latitude, + ImageURL: primaryImageURL, // Primary image + ImageURLs: entity.StringSlice(imageURLs), // All images + ExternalTower: tower.ExternalTower, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + return u.towerRepo.Post(newTower) +} + +func (u *towerUsecase) UpdateTowerWithMultipleImages(id uuid.UUID, tower req.UpdateTowerDTO, imageFiles []*multipart.FileHeader, replaceImages ...bool) error { + err := u.validate.Struct(tower) + if err != nil { + return err + } + + updates := make(map[string]interface{}) + + if tower.DeviceID != nil { + updates["DeviceID"] = *tower.DeviceID + } + if tower.TowerCode != nil { + updates["TowerCode"] = *tower.TowerCode + } + if tower.Longitude != nil { + updates["Longitude"] = *tower.Longitude + } + if tower.Latitude != nil { + updates["Latitude"] = *tower.Latitude + } + if tower.ExternalTower != nil { + updates["ExternalTower"] = *tower.ExternalTower + } + if tower.ImageURL != nil { + updates["ImageURL"] = *tower.ImageURL + } + + // Handle multiple image uploads + if len(imageFiles) > 0 { + // Get current tower to handle existing images + currentTower, err := u.towerRepo.GetByID(id) + if err != nil { + return err + } + + // Save new images + newImageURLs, err := helper.SaveTowerImagesBulk(imageFiles) + if err != nil { + return err + } + + var finalImageURLs []string + shouldReplace := len(replaceImages) > 0 && replaceImages[0] + + if shouldReplace { + // Replace all images - delete old ones + for _, oldImageURL := range currentTower.GetAllImageURLs() { + helper.DeleteTowerImage(oldImageURL) + } + finalImageURLs = newImageURLs + } else { + // Append to existing images + existingImages := currentTower.GetAllImageURLs() + finalImageURLs = append(existingImages, newImageURLs...) + } + + // Update primary image (first image in the final list) + if len(finalImageURLs) > 0 && finalImageURLs[0] != "" { + updates["ImageURL"] = finalImageURLs[0] + } + + // Update all images + updates["ImageURLs"] = entity.StringSlice(finalImageURLs) + } + + // If no fields are updated, return an error + if len(updates) == 0 { + return errors.New("no fields to update") + } + + updates["UpdatedAt"] = time.Now() + + return u.towerRepo.Update(id, updates) +} + func (u *towerUsecase) Post(tower req.TowerDTO, imageFile *multipart.FileHeader) error { err := u.validate.Struct(tower) @@ -108,6 +240,42 @@ func (u *towerUsecase) GetByID(id uuid.UUID) (res.TowerResponse, error) { return towerResp, nil } +func (u *towerUsecase) BulkUploadImages(towers []req.BulkTowerImageUploadDTO, imageFiles []*multipart.FileHeader) error { + // Validate input + if len(towers) != len(imageFiles) { + return fmt.Errorf("number of towers (%d) must match number of image files (%d)", len(towers), len(imageFiles)) + } + + if len(towers) == 0 { + return fmt.Errorf("no towers provided") + } + + // Validate all tower IDs exist + for i, tower := range towers { + _, err := u.towerRepo.GetByID(tower.TowerID) + if err != nil { + return fmt.Errorf("tower %d with ID %s not found", i, tower.TowerID) + } + } + + // Save all images + imageURLs, err := helper.SaveTowerImagesBulk(imageFiles) + if err != nil { + return fmt.Errorf("failed to save images: %w", err) + } + + // Create update map + updates := make(map[uuid.UUID]string) + for i, tower := range towers { + if i < len(imageURLs) && imageURLs[i] != "" { + updates[tower.TowerID] = imageURLs[i] + } + } + + // Update database + return u.towerRepo.BulkUpdateImages(updates) +} + func (u *towerUsecase) UpdateTower(id uuid.UUID, tower req.UpdateTowerDTO, imageFile *multipart.FileHeader) error { err := u.validate.Struct(tower) if err != nil { diff --git a/utils/helper/deviceHelperRes.go b/utils/helper/deviceHelperRes.go index 3f8a31f..283e647 100644 --- a/utils/helper/deviceHelperRes.go +++ b/utils/helper/deviceHelperRes.go @@ -1,7 +1,6 @@ package helper import ( - "fmt" "log" "users_management/m/model/dto/res" "users_management/m/model/entity" @@ -28,11 +27,19 @@ func ConvertToDeviceResponse(devices []entity.Device, geocoder service.Geocoding var responses []res.DeviceResponse for _, device := range devices { - // Get address from coordinates - address, err := geocoder.GetAddressFromCoordinates(device.Latitude, device.Longitude) - log.Printf("Geocoding for device %s: %.6f, %.6f -> %s", device.DeviceCode, device.Latitude, device.Longitude, address) - if err != nil { - address = fmt.Sprintf("Coordinates: %.6f, %.6f", device.Latitude, device.Longitude) + address := "Address not found" + if geocoder != nil { + if addr, err := geocoder.GetAddressFromCoordinates(device.Latitude, device.Longitude); err == nil { + address = addr + } + } + + // Get all image URLs using the entity method + allImageURLs := device.GetAllImageURLs() + + // Handle empty slice vs nil for JSON response + if len(allImageURLs) == 0 { + allImageURLs = []string{} // Return empty array instead of nil } response := res.DeviceResponse{ @@ -41,12 +48,14 @@ func ConvertToDeviceResponse(devices []entity.Device, geocoder service.Geocoding DeviceType: string(device.DeviceType), Longitude: device.Longitude, Latitude: device.Latitude, - Address: address, PortAmount: device.PortAmount, - Status: string(device.Status), - Province: device.Province, // Nullable field - City: device.City, // Nullable field - District: device.District, // Nullable field + Status: string(device.Status), + Address: address, + Province: device.Province, + City: device.City, + District: device.District, + ImageURL: device.ImageURL, // Primary image (can be null) + ImageURLs: allImageURLs, // All images (empty array if none) CreatedAt: device.CreatedAt, UpdatedAt: device.UpdatedAt, } @@ -56,36 +65,44 @@ func ConvertToDeviceResponse(devices []entity.Device, geocoder service.Geocoding return responses, nil } -func ConvertToDeviceResponseId (devices entity.Device, geocoder service.GeocodingService) (res.DeviceResponse, error) { - var address string +func ConvertToDeviceResponseId(device entity.Device, geocoder service.GeocodingService) (res.DeviceResponse, error) { + address := "Address not found" + + if geocoder != nil { + if addr, err := geocoder.GetAddressFromCoordinates(device.Latitude, device.Longitude); err == nil { + address = addr + } else { + log.Printf("Geocoding error for device %s: %v", device.DeviceCode, err) + } + } else { + log.Println("WARNING: Geocoder is nil") + } - if geocoder != nil { - generatedAddress, err := geocoder.GetAddressFromCoordinates(devices.Latitude, devices.Longitude) - if err != nil { - // Log specific geocoding error - log.Printf("Geocoding error for device %s: %v", devices.DeviceCode, err) - } else { - address = generatedAddress - } - } else{ - log.Println("WARNING: Geocoder is nil") - } + // Get all image URLs using the entity method + allImageURLs := device.GetAllImageURLs() + + // Handle empty slice vs nil for JSON response + if len(allImageURLs) == 0 { + allImageURLs = []string{} // Return empty array instead of nil + } - deviceResp := res.DeviceResponse{ - ID: devices.ID, - DeviceCode: devices.DeviceCode, - DeviceType: string(devices.DeviceType), - Longitude: devices.Longitude, - Latitude: devices.Latitude, - Address: address, - Province: devices.Province, - City: devices.City, - District: devices.District, - PortAmount: devices.PortAmount, - Status: string(devices.Status), - CreatedAt: devices.CreatedAt, - UpdatedAt: devices.UpdatedAt, - } + deviceResp := res.DeviceResponse{ + ID: device.ID, + DeviceCode: device.DeviceCode, + DeviceType: string(device.DeviceType), + Longitude: device.Longitude, + Latitude: device.Latitude, + Address: address, + Province: device.Province, + City: device.City, + District: device.District, + PortAmount: device.PortAmount, + Status: string(device.Status), + ImageURL: device.ImageURL, // Primary image (can be null) + ImageURLs: allImageURLs, // All images (empty array if none) + CreatedAt: device.CreatedAt, + UpdatedAt: device.UpdatedAt, + } - return deviceResp, nil + return deviceResp, nil } \ No newline at end of file diff --git a/utils/helper/device_details.go b/utils/helper/device_details.go index 201c28d..7c79676 100644 --- a/utils/helper/device_details.go +++ b/utils/helper/device_details.go @@ -105,6 +105,7 @@ func ConvertToDeviceDetailsResponse(device entity.DeviceDetails, geocoder servic Distance: distance, ExternalTower: externalTower, // This will be null if tower.ExternalTower is nil ImageURL: imageURL, + ImageURLs: tower.GetAllImageURLs(), // Get all images as a slice } towerInfos = append(towerInfos, info) } @@ -196,6 +197,19 @@ func ConvertToDeviceDetailsResponse(device entity.DeviceDetails, geocoder servic // Get customer names customerNames = device.DevicePort.GetCustomerNamesOnly() } + + var deviceImageURLs []string + if device.ImageURL != nil && *device.ImageURL != "" { + deviceImageURLs = append(deviceImageURLs, *device.ImageURL) + } + + // Use deviceImageURLs instead of trying to call a non-existent method + allImageURLs := deviceImageURLs + + // Handle empty slice vs nil for JSON response + if len(allImageURLs) == 0 { + allImageURLs = []string{} // Return empty array instead of nil + } response := res.DeviceDetailsResponse{ ID: device.ID, @@ -215,6 +229,7 @@ func ConvertToDeviceDetailsResponse(device entity.DeviceDetails, geocoder servic City: device.City, District: device.District, ImageURL: device.ImageURL, + ImageURLs: allImageURLs, // Use allImageURLs to ensure empty array instead of nil Backbones: backboneInfos, Fishbones: fishboneInfos, Towers: towerInfos, diff --git a/utils/helper/image_uploader.go b/utils/helper/image_uploader.go index 3c83c79..58bf893 100644 --- a/utils/helper/image_uploader.go +++ b/utils/helper/image_uploader.go @@ -14,9 +14,145 @@ import ( const ( MaxFileSize = 5 << 20 // 5MB - UploadDir = "./uploads/towers" + TowerUploadDir = "./uploads/towers" + DeviceUploadDir = "./uploads/devices" ) +func SaveDeviceImagesBulk(files []*multipart.FileHeader) ([]string, error) { + var imageURLs []string + + for i, file := range files { + if file == nil { + imageURLs = append(imageURLs, "") + continue + } + + // Validate file + if err := validateImageFile(file); err != nil { + return nil, fmt.Errorf("file %d: %w", i, err) + } + + // Create upload directory if it doesn't exist + if err := os.MkdirAll(DeviceUploadDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create upload directory: %v", err) + } + + // Generate unique filename + ext := strings.ToLower(filepath.Ext(file.Filename)) + filename := fmt.Sprintf("%s_%d%s", uuid.New().String(), time.Now().Unix(), ext) + filePath := filepath.Join(DeviceUploadDir, filename) + + // Save file + if err := saveFile(file, filePath); err != nil { + return nil, fmt.Errorf("failed to save file %d: %w", i, err) + } + + imageURL := fmt.Sprintf("/uploads/devices/%s", filename) + imageURLs = append(imageURLs, imageURL) + } + + return imageURLs, nil +} +func SaveTowerImagesBulk(files []*multipart.FileHeader) ([]string, error) { + var imageURLs []string + + for i, file := range files { + if file == nil { + imageURLs = append(imageURLs, "") + continue + } + + // Validate file + if err := validateImageFile(file); err != nil { + return nil, fmt.Errorf("file %d: %w", i, err) + } + + // Create upload directory if it doesn't exist + if err := os.MkdirAll(TowerUploadDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create upload directory: %v", err) + } + + // Generate unique filename + ext := strings.ToLower(filepath.Ext(file.Filename)) + filename := fmt.Sprintf("%s_%d%s", uuid.New().String(), time.Now().Unix(), ext) + filePath := filepath.Join(TowerUploadDir, filename) + + // Save file + if err := saveFile(file, filePath); err != nil { + return nil, fmt.Errorf("failed to save file %d: %w", i, err) + } + + imageURL := fmt.Sprintf("/uploads/towers/%s", filename) + imageURLs = append(imageURLs, imageURL) + } + + return imageURLs, nil +} + +// Helper functions +func validateImageFile(file *multipart.FileHeader) error { + // Validate file size + if file.Size > MaxFileSize { + return fmt.Errorf("file size exceeds 5MB limit") + } + + // Validate file type + allowedTypes := []string{".jpg", ".jpeg", ".png", ".webp"} + ext := strings.ToLower(filepath.Ext(file.Filename)) + isAllowed := false + for _, allowedType := range allowedTypes { + if ext == allowedType { + isAllowed = true + break + } + } + if !isAllowed { + return fmt.Errorf("file type not allowed. Only jpg, jpeg, png, webp are allowed") + } + + return nil +} + +func DeleteDeviceImage(imageURL string) error { + if imageURL == "" { + return nil + } + + // Extract filename from URL + filename := filepath.Base(imageURL) + filePath := filepath.Join(DeviceUploadDir, filename) + + // Check if file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return nil // File doesn't exist, nothing to delete + } + + // Delete file + return os.Remove(filePath) +} + +func saveFile(file *multipart.FileHeader, filePath string) error { + // Open uploaded file + src, err := file.Open() + if err != nil { + return fmt.Errorf("failed to open uploaded file: %v", err) + } + defer src.Close() + + // Create destination file + dst, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create destination file: %v", err) + } + defer dst.Close() + + // Copy file + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("failed to copy file: %v", err) + } + + return nil +} // SaveTowerImage saves uploaded image and returns the URL func SaveTowerImage(file *multipart.FileHeader) (string, error) { // Validate file size @@ -39,13 +175,13 @@ func SaveTowerImage(file *multipart.FileHeader) (string, error) { } // Create upload directory if it doesn't exist - if err := os.MkdirAll(UploadDir, 0755); err != nil { + if err := os.MkdirAll(TowerUploadDir, 0755); err != nil { return "", fmt.Errorf("failed to create upload directory: %v", err) } // Generate unique filename filename := fmt.Sprintf("%s_%d%s", uuid.New().String(), time.Now().Unix(), ext) - filePath := filepath.Join(UploadDir, filename) + filePath := filepath.Join(TowerUploadDir, filename) // Open uploaded file src, err := file.Open() @@ -78,7 +214,7 @@ func DeleteTowerImage(imageURL string) error { // Extract filename from URL filename := filepath.Base(imageURL) - filePath := filepath.Join(UploadDir, filename) + filePath := filepath.Join(TowerUploadDir, filename) // Check if file exists if _, err := os.Stat(filePath); os.IsNotExist(err) { diff --git a/utils/helper/towerHelperRes.go b/utils/helper/towerHelperRes.go index ad898d3..fde3648 100644 --- a/utils/helper/towerHelperRes.go +++ b/utils/helper/towerHelperRes.go @@ -12,6 +12,7 @@ func ConvertToTowerResponses(towers []entity.Tower, geocoder service.GeocodingSe for _, tower := range towers { var address string + allImageURLs := tower.GetAllImageURLs() if geocoder != nil { generatedAddress, err := geocoder.GetAddressFromCoordinates(tower.Latitude, tower.Longitude) @@ -23,7 +24,7 @@ func ConvertToTowerResponses(towers []entity.Tower, geocoder service.GeocodingSe } var deviceCode *string - if tower.Device != nil { + if tower.Device.DeviceCode != "" { deviceCode = &tower.Device.DeviceCode } @@ -35,6 +36,7 @@ func ConvertToTowerResponses(towers []entity.Tower, geocoder service.GeocodingSe Latitude: tower.Latitude, Address: address, ImageURL: tower.ImageURL, + ImageURLs: allImageURLs, // All images ExternalTower: tower.ExternalTower, // Now nullable CreatedAt: tower.CreatedAt, UpdatedAt: tower.UpdatedAt, @@ -57,9 +59,11 @@ func ConvertToTowerIDResponses(tower entity.Tower, geocoder service.GeocodingSer } var deviceCode *string - if tower.Device != nil { + if tower.Device.DeviceCode != "" { deviceCode = &tower.Device.DeviceCode } + // Get all image URLs + allImageURLs := tower.GetAllImageURLs() towerResp := res.TowerResponse{ ID: tower.ID, @@ -69,6 +73,7 @@ func ConvertToTowerIDResponses(tower entity.Tower, geocoder service.GeocodingSer Latitude: tower.Latitude, Address: address, ImageURL: tower.ImageURL, + ImageURLs: allImageURLs, // All images ExternalTower: tower.ExternalTower, // Now nullable CreatedAt: tower.CreatedAt, UpdatedAt: tower.UpdatedAt, diff --git a/utils/migrations/tower_migrations.go b/utils/migrations/tower_migrations.go new file mode 100644 index 0000000..0848a42 --- /dev/null +++ b/utils/migrations/tower_migrations.go @@ -0,0 +1,76 @@ +// Create utils/migration/tower_migration.go +package migrations + +import ( + "fmt" + "gorm.io/gorm" +) + +func MigrateTowerConstraints(db *gorm.DB) error { + // First, check if the constraint exists before trying to drop it + var constraintExists bool + err := db.Raw(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'uni_towers_tower_code' + AND table_name = 'towers' + ) + `).Scan(&constraintExists).Error + + if err != nil { + return fmt.Errorf("failed to check constraint existence: %w", err) + } + + // Only drop the constraint if it exists + if constraintExists { + err = db.Exec("ALTER TABLE towers DROP CONSTRAINT uni_towers_tower_code").Error + if err != nil { + return fmt.Errorf("failed to drop constraint: %w", err) + } + } + + // Check if the new index exists + var indexExists bool + err = db.Raw(` + SELECT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE indexname = 'idx_towers_tower_code' + AND tablename = 'towers' + ) + `).Scan(&indexExists).Error + + if err != nil { + return fmt.Errorf("failed to check index existence: %w", err) + } + + // Create the new unique index if it doesn't exist + if !indexExists { + err = db.Exec("CREATE UNIQUE INDEX idx_towers_tower_code ON towers (tower_code)").Error + if err != nil { + return fmt.Errorf("failed to create unique index: %w", err) + } + } + + // Add image_urls column if it doesn't exist + var columnExists bool + err = db.Raw(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'towers' + AND column_name = 'image_urls' + ) + `).Scan(&columnExists).Error + + if err != nil { + return fmt.Errorf("failed to check column existence: %w", err) + } + + if !columnExists { + err = db.Exec("ALTER TABLE towers ADD COLUMN image_urls JSONB").Error + if err != nil { + return fmt.Errorf("failed to add image_urls column: %w", err) + } + } + + return nil +} \ No newline at end of file