diff --git a/config/config.go b/config/config.go index 0ef42ce..b20863e 100644 --- a/config/config.go +++ b/config/config.go @@ -2,8 +2,11 @@ package config import ( "errors" - "github.com/joho/godotenv" "os" + "strconv" + "time" + + "github.com/joho/godotenv" ) type DbConfig struct { @@ -13,10 +16,17 @@ type DbConfig struct { DBPort string DBName string } + +type AuthConfig struct { + UserAuthEnabled bool + JWTSecret string + TokenExpiry time.Duration +} type LoginConfig struct { LoginAPI string AuthMeAPI string LogoutAPI string + SkipSSLVerification bool } type TokenConfig struct { @@ -38,6 +48,7 @@ type Config struct { TokenApi ApiConfig LoginConfig + AuthConfig } func (c *Config) readConfig() error { @@ -67,12 +78,42 @@ func (c *Config) readConfig() error { TokenApiKey: os.Getenv("TOKEN_API_KEY"), } + skipSSL, err := strconv.ParseBool(os.Getenv("SKIP_SSL_VERIFICATION")) + if err != nil { + return errors.New("failed to read SKIP_SSL_VERIFICATION environment variable") + } + + c.LoginConfig = LoginConfig{ LoginAPI: os.Getenv("API_LOGIN_URL"), AuthMeAPI: os.Getenv("API_ME_URL"), LogoutAPI: os.Getenv("API_LOGOUT_URL"), + SkipSSLVerification: skipSSL, } + userAuthEnabled := true // Default to true for security + if authEnabledStr := os.Getenv("USER_AUTH_ENABLED"); authEnabledStr != "" { + var err error + userAuthEnabled, err = strconv.ParseBool(authEnabledStr) + if err != nil { + return errors.New("invalid USER_AUTH_ENABLED value, must be true or false") + } + } + + // Parse token expiry (default to 24 hours) + tokenExpiry := 24 * time.Hour + if expiryStr := os.Getenv("JWT_TOKEN_EXPIRY_HOURS"); expiryStr != "" { + if hours, err := strconv.Atoi(expiryStr); err == nil { + tokenExpiry = time.Duration(hours) * time.Hour + } + } + + c.AuthConfig = AuthConfig{ + UserAuthEnabled: userAuthEnabled, + JWTSecret: os.Getenv("JWT_SECRET"), + TokenExpiry: tokenExpiry, + } + if c.ApiConfig.ApiPort == "" { return errors.New("failed to read environment variables") } diff --git a/delivery/controller/activity_logs_controller.go b/delivery/controller/activity_logs_controller.go new file mode 100644 index 0000000..585cbcb --- /dev/null +++ b/delivery/controller/activity_logs_controller.go @@ -0,0 +1,145 @@ +package controller + +import ( + "net/http" + "strconv" + "users_management/m/config" + "users_management/m/middleware" + "users_management/m/usecase" + "users_management/m/utils/common" + "users_management/m/utils/helper" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type ActivityLogController struct { + activityLogUC usecase.ActivityLogUseCase + rg *gin.RouterGroup + cfg *config.Config // Add config field for middleware +} +func NewActivityLogController(activityLogUC usecase.ActivityLogUseCase, rg *gin.RouterGroup, cfg *config.Config) *ActivityLogController { + return &ActivityLogController{ + activityLogUC: activityLogUC, + rg: rg, + cfg: cfg, // Initialize config + } +} + + +func (c *ActivityLogController) Route() { + logs := c.rg.Group("/logs") + { + // Users can see their own logs (simplified response) + logs.GET("/my-logs", c.getMyLogs) + // Only admins and superadmins can see all logs (detailed response) + logs.GET("/all", middleware.ConditionalRequireAnyRole(c.cfg, "Admin", "SuperAdmin"), c.getAllLogs) + // Admins and superadmins can see teknisi logs specifically (detailed response) + logs.GET("/teknisi", middleware.ConditionalRequireAnyRole(c.cfg, "Admin", "SuperAdmin"), c.getTeknisinLogs) + } +} + +func (c *ActivityLogController) getMyLogs(ctx *gin.Context) { + userID, exists := ctx.Get("userID") + if !exists { + common.ErrorResponses(ctx, http.StatusUnauthorized, "User ID not found") + return + } + + uid, ok := userID.(uuid.UUID) + if !ok { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid user ID") + return + } + + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 10 + } + + logs, total, err := c.activityLogUC.GetUserLogs(uid, page, limit) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + // Convert to simplified response using helper + simplifiedLogs := helper.ConvertToActivityLogResponses(logs) + + response := gin.H{ + "logs": simplifiedLogs, + "total": total, + "page": page, + "limit": limit, + "total_pages": (total + int64(limit) - 1) / int64(limit), + } + + common.SingleResponses(ctx, "User logs retrieved successfully", response) +} + +func (c *ActivityLogController) getAllLogs(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 10 + } + + logs, total, err := c.activityLogUC.GetAllLogs(page, limit) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + // Convert to detailed response for admins using helper + detailedLogs := helper.ConvertToActivityLogDetailResponses(logs) + + response := gin.H{ + "logs": detailedLogs, + "total": total, + "page": page, + "limit": limit, + "total_pages": (total + int64(limit) - 1) / int64(limit), + } + + common.SingleResponses(ctx, "All logs retrieved successfully", response) +} + +func (c *ActivityLogController) getTeknisinLogs(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 10 + } + + logs, total, err := c.activityLogUC.GetTeknisinLogs(page, limit) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + // Convert to detailed response for admins using helper + detailedLogs := helper.ConvertToActivityLogDetailResponses(logs) + + response := gin.H{ + "logs": detailedLogs, + "total": total, + "page": page, + "limit": limit, + "total_pages": (total + int64(limit) - 1) / int64(limit), + } + + common.SingleResponses(ctx, "Teknisi logs retrieved successfully", response) +} \ No newline at end of file diff --git a/delivery/controller/auth_controller.go b/delivery/controller/auth_controller.go new file mode 100644 index 0000000..ea1aa9f --- /dev/null +++ b/delivery/controller/auth_controller.go @@ -0,0 +1,136 @@ +package controller + +import ( + "net/http" + "users_management/m/model/dto" + "users_management/m/usecase" + "users_management/m/utils/common" + + "github.com/gin-gonic/gin" +) + +type AuthController struct { + authUC usecase.AuthUsecase + rg *gin.RouterGroup +} + +func NewAuthController(authUC usecase.AuthUsecase, rg *gin.RouterGroup) *AuthController { + return &AuthController{ + authUC: authUC, + rg: rg, + } +} + +func (c *AuthController) Route() { + auth := c.rg.Group("/auth") + { + auth.POST("/login", c.login) + auth.POST("/logout", c.logout) + auth.POST("/validate", c.validateToken) + auth.POST("/test-token-expiry", c.testTokenExpiry) + } +} + +func (c *AuthController) login(ctx *gin.Context) { + var loginDTO dto.UserLoginDTO + if err := ctx.ShouldBindJSON(&loginDTO); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + token, role, name, err := c.authUC.Login(loginDTO) + if err != nil { + common.ErrorResponses(ctx, http.StatusUnauthorized, err.Error()) + return + } + + response := gin.H{ + "token": token, + "user": gin.H{ + "name": name, + "username": loginDTO.Username, + "role": role, + }, + "expires_in": "24h", + } + + common.SingleResponses(ctx, "Login successful", response) +} + +func (c *AuthController) logout(ctx *gin.Context) { + token := ctx.GetHeader("Authorization") + if token == "" { + common.ErrorResponses(ctx, http.StatusBadRequest, "Authorization token required") + return + } + + // Remove "Bearer " prefix + if len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + + err := c.authUC.Logout(token) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + common.SingleResponses(ctx, "Logout successful", nil) +} + +func (c *AuthController) validateToken(ctx *gin.Context) { + token := ctx.GetHeader("Authorization") + if token == "" { + common.ErrorResponses(ctx, http.StatusBadRequest, "Authorization token required") + return + } + + // Remove "Bearer " prefix + if len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + + username, err := c.authUC.ValidateToken(token) + if err != nil { + common.ErrorResponses(ctx, http.StatusUnauthorized, err.Error()) + return + } + + response := gin.H{ + "valid": true, + "username": username, + } + + common.SingleResponses(ctx, "Token is valid", response) +} + +func (c *AuthController) testTokenExpiry(ctx *gin.Context) { + token := ctx.GetHeader("Authorization") + if token == "" { + common.ErrorResponses(ctx, http.StatusBadRequest, "Authorization token required") + return + } + + // Remove "Bearer " prefix + if len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + + // Test if token is expired + _, err := c.authUC.ValidateToken(token) + if err != nil { + response := gin.H{ + "expired": true, + "message": err.Error(), + } + common.SingleResponses(ctx, "Token expiry test result", response) + return + } + + response := gin.H{ + "expired": false, + "message": "Token is still valid", + } + + common.SingleResponses(ctx, "Token expiry test result", response) +} \ No newline at end of file diff --git a/delivery/controller/backbone_controller.go b/delivery/controller/backbone_controller.go index f0b7344..8ce5c26 100644 --- a/delivery/controller/backbone_controller.go +++ b/delivery/controller/backbone_controller.go @@ -1,7 +1,9 @@ package controller import ( + "log" "net/http" + "users_management/m/config" "users_management/m/middleware" "users_management/m/model/dto/req" "users_management/m/usecase" @@ -10,32 +12,32 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" ) - type BackboneController struct { - bu usecase.BackboneUseCase - rg *gin.RouterGroup + bu usecase.BackboneUseCase + rg *gin.RouterGroup + cfg *config.Config } func (bc *BackboneController) Route() { rg := bc.rg.Group("/backbone") - - rg.Use(middleware.AuthMiddleware()) - rg.Use(middleware.CORSMiddleware()) - rg.Use(middleware.RateLimitMiddleware()) { rg.GET("", bc.GetBackbone()) - rg.POST("", bc.CreateBackbone()) rg.GET("/:uuid", bc.GetBackboneByID()) - rg.PUT("/:uuid", bc.UpdateBackbone()) + + rg.POST("", middleware.ConditionalRequireAnyRole(bc.cfg, "Teknisi", "Admin", "Super Admin"), bc.CreateBackbone()) + rg.PUT("/:uuid", middleware.ConditionalRequireAnyRole(bc.cfg, "Teknisi", "Admin", "Super Admin"), bc.UpdateBackbone()) + + } +} +func NewBackboneController(bu usecase.BackboneUseCase, rg *gin.RouterGroup, cfg *config.Config) *BackboneController { + return &BackboneController{ + bu: bu, + rg: rg, + cfg: cfg, } } -func NewBackboneController(bu usecase.BackboneUseCase, rg *gin.RouterGroup) *BackboneController { - return &BackboneController{ - bu: bu, - rg: rg, - } -} + func (bc *BackboneController) GetBackbone() gin.HandlerFunc { return func(c *gin.Context) { @@ -56,6 +58,7 @@ func (bc *BackboneController) CreateBackbone() gin.HandlerFunc { err := c.ShouldBindJSON(&backboneDTO) if err != nil { + log.Println("Error binding JSON:", err) common.ErrorResponses(c, http.StatusBadRequest, err.Error()) return } @@ -63,6 +66,7 @@ func (bc *BackboneController) CreateBackbone() gin.HandlerFunc { err = bc.bu.CreateBackbone(backboneDTO) if err != nil { + log.Println("Error creating backbone:", err) common.ErrorResponses(c, http.StatusBadRequest, err.Error()) return } diff --git a/delivery/controller/count_assets_controller.go b/delivery/controller/count_assets_controller.go new file mode 100644 index 0000000..27d8bed --- /dev/null +++ b/delivery/controller/count_assets_controller.go @@ -0,0 +1,35 @@ +package controller + +import ( + "net/http" + "users_management/m/usecase" + "users_management/m/utils/common" + + "github.com/gin-gonic/gin" +) + +type CountAssetsController struct { + countAssetsUC usecase.CountAssetsUseCase + rg *gin.RouterGroup +} + +func NewCountAssetsController(countAssetsUC usecase.CountAssetsUseCase, rg *gin.RouterGroup) *CountAssetsController { + return &CountAssetsController{ + countAssetsUC: countAssetsUC, + rg: rg, + } +} + +func (c *CountAssetsController) Route() { + c.rg.GET("/count-assets", c.getCounts) +} + +func (c *CountAssetsController) getCounts(ctx *gin.Context) { + counts, err := c.countAssetsUC.GetCounts() + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + common.SingleResponses(ctx, "Count assets retrieved successfully", counts) +} \ No newline at end of file diff --git a/delivery/controller/creatin_admin.go b/delivery/controller/creatin_admin.go new file mode 100644 index 0000000..2e52d50 --- /dev/null +++ b/delivery/controller/creatin_admin.go @@ -0,0 +1,140 @@ +package controller + +import ( + "net/http" + "time" + "users_management/m/model/entity" + "users_management/m/usecase" + "users_management/m/utils" + "users_management/m/utils/common" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type BypassController struct { + userUC usecase.UsersUsecase + rg *gin.RouterGroup +} + +func NewBypassController(userUC usecase.UsersUsecase, rg *gin.RouterGroup) *BypassController { + return &BypassController{ + userUC: userUC, + rg: rg, + } +} + +func (c *BypassController) Route() { + bypass := c.rg.Group("/bypass") + { + // Dangerous endpoint - should be secured or removed in production + bypass.POST("/create-super-admin", c.createSuperAdmin) + bypass.GET("/roles", c.getRoles) // Helper to get role IDs + } +} + +type CreateSuperAdminRequest struct { + Name string `json:"name" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required,min=6"` + NomorInduk string `json:"nomor_induk" binding:"required"` + SecretKey string `json:"secret_key" binding:"required"` // Extra security +} + +func (c *BypassController) createSuperAdmin(ctx *gin.Context) { + var req CreateSuperAdminRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + // Simple secret key check (change this in production) + if req.SecretKey != "create-super-admin-secret-2024" { + common.ErrorResponses(ctx, http.StatusForbidden, "Invalid secret key") + return + } + + // Check if username already exists + existingUser, err := c.userUC.GetUserByUsernameWithStatus(req.Username) + if err == nil && existingUser.ID != uuid.Nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Username already exists") + return + } + + // Check if nomor_induk already exists + if req.NomorInduk != "" { + existingUser, err := c.userUC.GetUserByNomorInduk(req.NomorInduk) + if err == nil && existingUser.ID != uuid.Nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Nomor induk already exists") + return + } + } + + // Get or create Super Admin role + superAdminRoleID, err := c.userUC.GetRoleByDepartment("Super Admin") + if err != nil { + // If Super Admin role doesn't exist, try Admin + superAdminRoleID, err = c.userUC.GetRoleByDepartment("Admin") + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, "No admin role found in system") + return + } + } + + // Hash password + hashedPassword, err := utils.HashPassword(req.Password) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, "Failed to hash password") + return + } + + // Create super admin user directly (bypass normal registration flow) + user := entity.User{ + ID: uuid.New(), + Name: req.Name, + Username: req.Username, + Password: hashedPassword, + NomorInduk: &req.NomorInduk, + RoleID: superAdminRoleID, + Status: entity.UserStatusApproved, // Directly approved + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err = c.userUC.CreateSuperAdminBypass(user) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, "Failed to create super admin: "+err.Error()) + return + } + + response := gin.H{ + "message": "Super admin created successfully", + "user": gin.H{ + "id": user.ID, + "name": user.Name, + "username": user.Username, + "nomor_induk": user.NomorInduk, + "status": string(user.Status), + }, + "warning": "This bypass endpoint should be disabled in production", + } + + common.SingleResponses(ctx, "Super admin created", response) +} + +func (c *BypassController) getRoles(ctx *gin.Context) { + // Helper endpoint to see available roles + roles := []gin.H{ + {"name": "Super Admin", "description": "Full system access"}, + {"name": "Admin", "description": "Administrative access"}, + {"name": "Teknisi", "description": "Technical user access"}, + {"name": "Manager", "description": "Management access"}, + } + + response := gin.H{ + "roles": roles, + "note": "Use the role name to get role ID via GetRoleByDepartment", + } + + common.SingleResponses(ctx, "Available roles", response) +} \ No newline at end of file diff --git a/delivery/controller/devicePort_controller.go b/delivery/controller/devicePort_controller.go index 568c707..3e615a6 100644 --- a/delivery/controller/devicePort_controller.go +++ b/delivery/controller/devicePort_controller.go @@ -2,7 +2,6 @@ package controller import ( "net/http" - "users_management/m/middleware" "users_management/m/model/dto/req" "users_management/m/usecase" "users_management/m/utils/common" @@ -18,9 +17,6 @@ type DevicePortController struct { func (dc *DevicePortController) Route() { rg := dc.rg.Group("/device-port") - rg.Use(middleware.AuthMiddleware()) - rg.Use(middleware.RateLimitMiddleware()) - rg.Use(middleware.CORSMiddleware()) { rg.GET("", dc.GetDevicePort()) rg.POST("", dc.CreateDevicePort()) diff --git a/delivery/controller/device_details.go b/delivery/controller/device_details.go new file mode 100644 index 0000000..e45118e --- /dev/null +++ b/delivery/controller/device_details.go @@ -0,0 +1,585 @@ +package controller + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + "strings" + "users_management/m/config" + "users_management/m/middleware" + "users_management/m/model/dto/req" + "users_management/m/model/dto/res" + "users_management/m/usecase" + "users_management/m/utils/common" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type DeviceDetailsController struct { + deviceDetailsUC usecase.DeviceDetailsUseCase + rg *gin.RouterGroup + cfg *config.Config // Add config field for middleware +} + +func NewDeviceDetailsController(deviceDetailsUC usecase.DeviceDetailsUseCase, rg *gin.RouterGroup, cfg *config.Config) *DeviceDetailsController { + return &DeviceDetailsController{ + deviceDetailsUC: deviceDetailsUC, + rg: rg, + cfg: cfg, + } +} + +func (c *DeviceDetailsController) Route() { + deviceDetails := c.rg.Group("/device-details") + deviceDetails.Use(middleware.ConditionalRequireAnyRole(c.cfg, "Teknisi", "Admin", "Super Admin")) + { + deviceDetails.GET("", c.getAllDeviceDetails) + deviceDetails.POST("", c.createDeviceDetails) + deviceDetails.GET("/:id", c.getDeviceDetailsByID) + deviceDetails.PUT("/:id", c.updateDeviceDetails) + deviceDetails.DELETE("/:id", c.deleteDeviceDetails) + deviceDetails.POST("/:id/recalculate-ports", c.recalculatePortUsage) + deviceDetails.POST("/:id/assign-customer", c.assignCustomer) + deviceDetails.PUT("/:id/port-usage", c.updatePortUsage) // New endpoint + deviceDetails.PUT("/:id/port-assignments", c.updatePortAssignments) // New endpoint + + deviceDetails.PUT("/:id/update-customer-by-port", c.updateCustomerByPort) // Updated endpoint name + deviceDetails.PUT("/:id/bulk-update-customers-by-port", c.bulkUpdateCustomersByPort) // Updated endpoint name + deviceDetails.DELETE("/:id/remove-customer-by-port", c.removeCustomerByPort) + + // delete images by filename + deviceDetails.DELETE("/:id/images/:filename", c.deleteDeviceImage) + deviceDetails.GET("/without-towers", c.getDevicesWithoutTowers) + + deviceDetails.GET("/without-connections", c.getDevicesWithoutConnections) + + + + } +} + + +func (c *DeviceDetailsController) getDevicesWithoutConnections(ctx *gin.Context) { + // Get device types from query parameters (optional) + deviceTypesParam := ctx.Query("device_types") + var deviceTypes []string + + if deviceTypesParam != "" { + // Split by comma if multiple types provided + deviceTypes = strings.Split(deviceTypesParam, ",") + // Trim whitespace and normalize case + for i, dt := range deviceTypes { + trimmed := strings.TrimSpace(dt) + if strings.ToUpper(trimmed) == "OTB" { + deviceTypes[i] = "OTB" + } else if strings.ToLower(trimmed) == "closure" { + deviceTypes[i] = "closure" + } else { + deviceTypes[i] = trimmed + } + } + } + + devices, err := c.deviceDetailsUC.GetDevicesWithoutConnections(deviceTypes) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + // Categorize devices by type for better response structure + closureDevices := make([]res.DeviceDetailsResponse, 0) + otbDevices := make([]res.DeviceDetailsResponse, 0) + + for _, device := range devices { + if device.DeviceType == "closure" { + closureDevices = append(closureDevices, device) + } else if device.DeviceType == "OTB" { + otbDevices = append(otbDevices, device) + } + } + + response := gin.H{ + "devices": devices, + "total": len(devices), + "breakdown": gin.H{ + "closure": gin.H{ + "count": len(closureDevices), + "devices": closureDevices, + }, + "otb": gin.H{ + "count": len(otbDevices), + "devices": otbDevices, + }, + }, + "filter": gin.H{ + "device_types": deviceTypes, + "criteria": "without_fishbones_and_backbones", + }, + } + + common.SingleResponses(ctx, "Devices without connections retrieved successfully", response) +} +func (c *DeviceDetailsController) getDevicesWithoutTowers(ctx *gin.Context) { + // Get device types from query parameters (optional) + deviceTypesParam := ctx.Query("device_types") + var deviceTypes []string + + if deviceTypesParam != "" { + // Split by comma if multiple types provided + deviceTypes = strings.Split(deviceTypesParam, ",") + // Trim whitespace + for i, dt := range deviceTypes { + deviceTypes[i] = strings.TrimSpace(strings.ToUpper(dt)) + } + } + + devices, err := c.deviceDetailsUC.GetDevicesWithoutTowers(deviceTypes) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + response := gin.H{ + "devices": devices, + "total": len(devices), + "filter": gin.H{ + "device_types": deviceTypes, + "without_towers": true, + }, + } + + common.SingleResponses(ctx, "Devices without towers retrieved successfully", response) +} + +func (c *DeviceDetailsController) deleteDeviceImage(ctx *gin.Context) { + // Parse device ID + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + // Get filename from URL parameter + filename := ctx.Param("filename") + if filename == "" { + common.ErrorResponses(ctx, http.StatusBadRequest, "Filename is required") + return + } + + // Validate filename format (basic security check) + if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid filename format") + return + } + + // Call use case to delete the image + err = c.deviceDetailsUC.DeleteDeviceImage(deviceID, filename) + if err != nil { + if err.Error() == "device not found" { + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + return + } + if err.Error() == "image not found in device" { + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + return + } + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, fmt.Sprintf("Image %s deleted successfully", filename), nil) +} + +func (c *DeviceDetailsController) updateCustomerByPort(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + var request req.UpdateCustomerByPortDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err = c.deviceDetailsUC.UpdateCustomerByPort(deviceID, request) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + if request.NewCustomerName == nil { + common.SingleResponses(ctx, fmt.Sprintf("Customer removed from port %d successfully", request.PortNumber), nil) + } else { + common.SingleResponses(ctx, fmt.Sprintf("Customer assigned to port %d successfully", request.PortNumber), nil) + } +} + +func (c *DeviceDetailsController) bulkUpdateCustomersByPort(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + var request req.BulkUpdateCustomersByPortDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err = c.deviceDetailsUC.BulkUpdateCustomersByPort(deviceID, request.Updates) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, fmt.Sprintf("%d port assignments updated successfully", len(request.Updates)), nil) +} + +func (c *DeviceDetailsController) removeCustomerByPort(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + var request req.RemoveCustomerByPortDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err = c.deviceDetailsUC.RemoveCustomerByPort(deviceID, request.PortNumber) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, fmt.Sprintf("Customer removed from port %d successfully", request.PortNumber), nil) +} + +func (c *DeviceDetailsController) assignCustomer(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + // Try to parse as array first + var arrayRequest []req.AssignMultipleCustomersDTO + + // Try to parse as single object + var singleRequest struct { + CustomerName string `json:"customer_name" binding:"required"` + PortNumber *int `json:"port_number,omitempty"` + } + + // Get raw JSON to determine the structure + rawData, err := ctx.GetRawData() + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid JSON data") + return + } + + // Reset the request body for subsequent parsing + ctx.Request.Body = io.NopCloser(bytes.NewBuffer(rawData)) + + // Check if it's an array by looking at the first character + trimmed := bytes.TrimSpace(rawData) + if len(trimmed) > 0 && trimmed[0] == '[' { + // It's an array + if err := json.Unmarshal(rawData, &arrayRequest); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid array format: "+err.Error()) + return + } + + // Process array of assignments + err = c.deviceDetailsUC.AssignMultipleCustomersToPort(deviceID, arrayRequest) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, fmt.Sprintf("%d customers assigned successfully", len(arrayRequest)), nil) + } else { + // It's a single object + if err := json.Unmarshal(rawData, &singleRequest); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid object format: "+err.Error()) + return + } + + // Process single assignment + err = c.deviceDetailsUC.AssignCustomerToPort(deviceID, singleRequest.CustomerName, singleRequest.PortNumber) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "Customer assigned successfully", nil) + } +} + + +func (c *DeviceDetailsController) getAllDeviceDetails(ctx *gin.Context) { + devices, err := c.deviceDetailsUC.GetAllDeviceDetails() + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + common.SingleResponses(ctx, "Device details retrieved successfully", devices) +} + +func (c *DeviceDetailsController) createDeviceDetails(ctx *gin.Context) { + var request req.DeviceDetailsDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err := c.deviceDetailsUC.CreateDeviceDetails(request) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "Device details created successfully", nil) +} + +func (c *DeviceDetailsController) getDeviceDetailsByID(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + device, err := c.deviceDetailsUC.GetDeviceDetailsByID(deviceID) + if err != nil { + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + return + } + + common.SingleResponses(ctx, "Device details retrieved successfully", device) +} + +func (c *DeviceDetailsController) updateDeviceDetails(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + 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 + } + + // 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 + } + + // Handle TowerID + if towerIDStr := ctx.PostForm("tower_id"); towerIDStr != "" { + if towerID, err := uuid.Parse(towerIDStr); err == nil { + deviceUpdateDTO.TowerID = &towerID + } else { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid tower ID format") + return + } + } + + + if oltIDStr := ctx.PostForm("olt_id"); oltIDStr != "" { + if oltID, err := uuid.Parse(oltIDStr); err == nil { + deviceUpdateDTO.OLTID = &oltID + } else { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid OLT ID format") + return + } + } + + + // 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 + } + + common.SingleResponses(ctx, "Device details updated successfully", nil) +} + +func (c *DeviceDetailsController) deleteDeviceDetails(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + err = c.deviceDetailsUC.DeleteDeviceDetails(deviceID) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "Device details deleted successfully", nil) +} + +func (c *DeviceDetailsController) recalculatePortUsage(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + err = c.deviceDetailsUC.RecalculatePortUsage(deviceID) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + common.SingleResponses(ctx, "Port usage recalculated successfully", nil) +} + +func (c *DeviceDetailsController) updatePortUsage(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + var request req.UpdatePortUsageDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err = c.deviceDetailsUC.UpdatePortUsage(deviceID, request.PortUsed) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "Port usage updated successfully", nil) +} + +func (c *DeviceDetailsController) updatePortAssignments(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + var request req.UpdatePortAssignmentsDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err = c.deviceDetailsUC.UpdatePortAssignments(deviceID, request.PortAssignments) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "Port assignments updated successfully", nil) +} \ No newline at end of file diff --git a/delivery/controller/device_inspections_controller.go b/delivery/controller/device_inspections_controller.go new file mode 100644 index 0000000..36a98eb --- /dev/null +++ b/delivery/controller/device_inspections_controller.go @@ -0,0 +1,255 @@ +package controller + +import ( + "net/http" + "strconv" + "users_management/m/middleware" + "users_management/m/model/dto/req" + "users_management/m/usecase" + "users_management/m/utils/common" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type DeviceInspectionController struct { + inspectionUC usecase.DeviceInspectionUseCase + rg *gin.RouterGroup +} + +func NewDeviceInspectionController(inspectionUC usecase.DeviceInspectionUseCase, rg *gin.RouterGroup) *DeviceInspectionController { + return &DeviceInspectionController{ + inspectionUC: inspectionUC, + rg: rg, + } +} + +func (c *DeviceInspectionController) Route() { + inspections := c.rg.Group("/device-inspections") + { + // Teknisi can create and read their own inspections + inspections.POST("", middleware.RequireAnyRole("Teknisi", "Admin", "Superadmin"), c.createInspection) + inspections.GET("/my-inspections", middleware.RequireAnyRole("Teknisi", "Admin", "Superadmin"), c.getMyInspections) + + // Admin and Superadmin can see all inspections + inspections.GET("", middleware.RequireAdminRole(), c.getAllInspections) + inspections.GET("/:id", middleware.RequireAnyRole("Teknisi", "Admin", "Superadmin"), c.getInspectionByID) + + // Update inspections - teknisi can update their own, admin can update any + inspections.PUT("/:id", middleware.RequireAnyRole("Teknisi", "Admin", "Superadmin"), c.updateInspection) + + // Only admin can approve inspections + inspections.PATCH("/:id/approve", middleware.RequireAdminRole(), c.approveInspection) + } +} + +func (c *DeviceInspectionController) createInspection(ctx *gin.Context) { + userID, exists := ctx.Get("userID") + if !exists { + common.ErrorResponses(ctx, http.StatusUnauthorized, "User ID not found") + return + } + + uid, ok := userID.(uuid.UUID) + if !ok { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid user ID") + return + } + + var inspectionDTO req.DeviceInspectionDTO + if err := ctx.ShouldBindJSON(&inspectionDTO); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err := c.inspectionUC.CreateInspection(uid, inspectionDTO) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "Device inspection created successfully", nil) +} + +func (c *DeviceInspectionController) getAllInspections(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 10 + } + + inspections, total, err := c.inspectionUC.GetAllInspections(page, limit) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + response := gin.H{ + "inspections": inspections, + "total": total, + "page": page, + "limit": limit, + "total_pages": (total + int64(limit) - 1) / int64(limit), + } + + common.SingleResponses(ctx, "All inspections retrieved successfully", response) +} + +func (c *DeviceInspectionController) getMyInspections(ctx *gin.Context) { + userID, exists := ctx.Get("userID") + if !exists { + common.ErrorResponses(ctx, http.StatusUnauthorized, "User ID not found") + return + } + + uid, ok := userID.(uuid.UUID) + if !ok { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid user ID") + return + } + + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 10 + } + + inspections, total, err := c.inspectionUC.GetUserInspections(uid, page, limit) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + response := gin.H{ + "inspections": inspections, + "total": total, + "page": page, + "limit": limit, + "total_pages": (total + int64(limit) - 1) / int64(limit), + } + + common.SingleResponses(ctx, "User inspections retrieved successfully", response) +} + +func (c *DeviceInspectionController) getInspectionByID(ctx *gin.Context) { + id := ctx.Param("id") + inspectionID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid inspection ID") + return + } + + userID, exists := ctx.Get("userID") + if !exists { + common.ErrorResponses(ctx, http.StatusUnauthorized, "User ID not found") + return + } + + uid, ok := userID.(uuid.UUID) + if !ok { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid user ID") + return + } + + userRole, exists := ctx.Get("userRole") + if !exists { + common.ErrorResponses(ctx, http.StatusUnauthorized, "User role not found") + return + } + + role, ok := userRole.(string) + if !ok { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid user role") + return + } + + // Get the inspection first + inspection, err := c.inspectionUC.GetInspectionByID(inspectionID) + if err != nil { + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + return + } + + // For teknisi, check if they own this inspection + if role == "Teknisi" { + isOwner, err := c.inspectionUC.CheckInspectionOwnership(inspectionID, uid) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, "Error checking inspection ownership") + return + } + if !isOwner { + common.ErrorResponses(ctx, http.StatusForbidden, "Unauthorized: you can only view your own inspections") + return + } + } + + common.SingleResponses(ctx, "Inspection retrieved successfully", inspection) +} +func (c *DeviceInspectionController) updateInspection(ctx *gin.Context) { + id := ctx.Param("id") + inspectionID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid inspection ID") + return + } + + userID, exists := ctx.Get("userID") + if !exists { + common.ErrorResponses(ctx, http.StatusUnauthorized, "User ID not found") + return + } + + uid, ok := userID.(uuid.UUID) + if !ok { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid user ID") + return + } + + userRole, _ := ctx.Get("userRole") + role, _ := userRole.(string) + + var inspectionDTO req.UpdateDeviceInspectionDTO + if err := ctx.ShouldBindJSON(&inspectionDTO); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err = c.inspectionUC.UpdateInspection(inspectionID, uid, inspectionDTO, role) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "Inspection updated successfully", nil) +} + +func (c *DeviceInspectionController) approveInspection(ctx *gin.Context) { + id := ctx.Param("id") + inspectionID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid inspection ID") + return + } + + var approvalDTO req.ApproveInspectionDTO + if err := ctx.ShouldBindJSON(&approvalDTO); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err = c.inspectionUC.ApproveInspection(inspectionID, approvalDTO) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "Inspection approval updated successfully", nil) +} \ No newline at end of file diff --git a/delivery/controller/devices_controller.go b/delivery/controller/devices_controller.go index 20f3f59..b072ba6 100644 --- a/delivery/controller/devices_controller.go +++ b/delivery/controller/devices_controller.go @@ -1,7 +1,13 @@ package controller import ( + "encoding/json" + "fmt" + "mime/multipart" "net/http" + "strconv" + "strings" + "users_management/m/config" "users_management/m/middleware" "users_management/m/model/dto/req" "users_management/m/usecase" @@ -14,55 +20,269 @@ import ( type DeviceController struct { du usecase.DeviceUseCase rg *gin.RouterGroup + cfg *config.Config +} + +func NewDeviceController(du usecase.DeviceUseCase, rg *gin.RouterGroup, cfg *config.Config) *DeviceController { + return &DeviceController{ + du: du, + rg: rg, + cfg: cfg, + } } func (dc *DeviceController) Route() { rg := dc.rg.Group("/devices") - rg.Use(middleware.AuthMiddleware()) - rg.Use(middleware.RateLimitMiddleware()) - rg.Use(middleware.CORSMiddleware()) + rg.Use(middleware.ConditionalRequireAnyRole(dc.cfg,"Teknisi", "Admin", "Super Admin")) { rg.POST("", dc.CreateDevice()) rg.GET("", dc.GetAllDevices()) rg.GET("/:uuid", dc.GetDeviceByID()) rg.PUT("/:uuid", dc.UpdateDevice()) + + rg.POST("/bulk-upload-images", dc.BulkUploadImages()) } } -func NewDeviceController(du usecase.DeviceUseCase, rg *gin.RouterGroup) *DeviceController { - return &DeviceController{ - du: du, - rg: rg, - } -} + +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) { - var deviceDTO req.DeviceDTO - err := c.ShouldBindJSON(&deviceDTO) + 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 + } - 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 + } - err = dc.du.CreateDevice(deviceDTO) + common.SingleResponses(c, "Device has been created", nil) + return + } - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - 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 + } - common.SingleResponses(c, "Device has been created", nil) - } + // 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 + } + } + + var towerID *uuid.UUID + if towerIDStr := c.PostForm("tower_id"); towerIDStr != "" { + if parsedTowerID, err := uuid.Parse(towerIDStr); err == nil { + towerID = &parsedTowerID + } else { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid tower ID format") + return + } + } + + // Handle OLTID in form data + var oltID *uuid.UUID + if oltIDStr := c.PostForm("olt_id"); oltIDStr != "" { + if parsedOLTID, err := uuid.Parse(oltIDStr); err == nil { + oltID = &parsedOLTID + } else { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid OLT ID format") + return + } + } + + // Create DTO + deviceDTO := req.DeviceDTO{ + DeviceCode: deviceCode, + DeviceType: deviceType, + Longitude: longitude, + Latitude: latitude, + PortAmount: portAmount, + Status: status, + TowerID: towerID, // Add TowerID + OLTID: oltID, // Add OLTID + } + + // 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) { + + deviceType := c.Query("type") + if deviceType != "" { + deviceResp, err := dc.du.GetByType(deviceType) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Device type not found") + return + } + common.SingleResponses(c, "Success", deviceResp) + return + } + devices, err := dc.du.GetAllDevices() if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + common.ErrorResponses(c, http.StatusBadRequest, "something went wrong") return } @@ -75,12 +295,12 @@ func (dc *DeviceController) GetDeviceByID() gin.HandlerFunc { id := c.Param("uuid") uuid, err := uuid.Parse(id) if err != nil{ - common.ErrorResponses(c, http.StatusBadGateway, err.Error()) + common.ErrorResponses(c, http.StatusBadGateway,"Invalid UUID") return } device, err := dc.du.GetByID(uuid) if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + common.ErrorResponses(c, http.StatusBadRequest, "Device not found") return } @@ -89,29 +309,128 @@ 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, err.Error()) - return - } + return func(c *gin.Context) { + id := c.Param("uuid") + deviceUUID, err := uuid.Parse(id) // Change variable name from 'uuid' to 'deviceUUID' + 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, err.Error()) - return - } + err = dc.du.UpdateDevice(deviceUUID, deviceDTO) // Use the new variable name + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) // Also fix error message + return + } - err = dc.du.UpdateDevice(uuid, deviceDTO) + common.SingleResponses(c, "Device has been updated", nil) + return + } - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - 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 + } + + // Handle TowerID in form data + if towerIDStr := c.PostForm("tower_id"); towerIDStr != "" { + if towerID, err := uuid.Parse(towerIDStr); err == nil { + deviceUpdateDTO.TowerID = &towerID + } else { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid tower ID format") + return + } + } + + // Handle OLTID in form data + if oltIDStr := c.PostForm("olt_id"); oltIDStr != "" { + if oltID, err := uuid.Parse(oltIDStr); err == nil { + deviceUpdateDTO.OLTID = &oltID + } else { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid OLT ID format") + return + } + } + + // 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(deviceUUID, deviceUpdateDTO, imageFiles, replaceImages) // Use the new variable name + 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/fishbone_controller.go b/delivery/controller/fishbone_controller.go index 1bc0b44..7e320dc 100644 --- a/delivery/controller/fishbone_controller.go +++ b/delivery/controller/fishbone_controller.go @@ -2,6 +2,7 @@ package controller import ( "net/http" + "users_management/m/config" "users_management/m/middleware" "users_management/m/model/dto/req" "users_management/m/usecase" @@ -14,105 +15,175 @@ import ( type FishboneController struct { fu usecase.FishboneUseCase rg *gin.RouterGroup + cfg *config.Config +} + +func NewFishboneController(fu usecase.FishboneUseCase, rg *gin.RouterGroup, cfg *config.Config) *FishboneController { + return &FishboneController{ + fu: fu, + rg: rg, + cfg: cfg, + } } func (fc *FishboneController) Route() { rg := fc.rg.Group("/fishbone") - rg.Use(middleware.AuthMiddleware()) - rg.Use(middleware.RateLimitMiddleware()) - rg.Use(middleware.CORSMiddleware()) + rg.Use(middleware.ConditionalRequireAnyRole(fc.cfg,"Teknisi", "Admin", "Super Admin")) + // Apply middleware to all routes { rg.GET("", fc.GetFishbone()) rg.POST("", fc.CreateFishbone()) rg.GET("/:uuid", fc.GetFishboneByID()) rg.PUT("/:uuid", fc.UpdateFishbone()) + rg.GET("/backbone/:backbone_id", fc.GetFishboneByBackboneID()) } } -func NewFishboneController(fu usecase.FishboneUseCase, rg *gin.RouterGroup) *FishboneController { - return &FishboneController{ - fu: fu, - rg: rg, - } + +func (fc *FishboneController) GetFishboneByBackboneID() gin.HandlerFunc { + return func(c *gin.Context) { + backboneIDStr := c.Param("backbone_id") + backboneID, err := uuid.Parse(backboneIDStr) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid backbone ID format") + return + } + + fishbones, err := fc.fu.GetByBackboneID(backboneID) + if err != nil { + common.ErrorResponses(c, http.StatusInternalServerError, err.Error()) + return + } + + if len(fishbones) == 0 { + common.SingleResponses(c, "No fishbones found for this backbone", []interface{}{}) + return + } + + common.SingleResponses(c, "Success", fishbones) + } } func (fc *FishboneController) GetFishbone() gin.HandlerFunc { - return func(c *gin.Context) { - fishbones, err := fc.fu.GetAllFishbone() + return func(c *gin.Context) { + fishbones, err := fc.fu.GetAllFishbone() - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } - common.SingleResponses(c, "Success", fishbones) - } + common.SingleResponses(c, "Success", fishbones) + } } func (fc *FishboneController) CreateFishbone() gin.HandlerFunc { - return func(c *gin.Context) { - var fishboneDTO req.FishboneDTO - err := c.ShouldBindJSON(&fishboneDTO) + return func(c *gin.Context) { + var fishboneDTO req.FishboneDTO + err := c.ShouldBindJSON(&fishboneDTO) - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } - err = fc.fu.CreateFishbone(fishboneDTO) + err = fc.fu.CreateFishbone(fishboneDTO) - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } - common.SingleResponses(c, "Fishbone has been created", nil) - } + common.SingleResponses(c, "Fishbone has been created successfully", nil) + } } func (fc *FishboneController) GetFishboneByID() gin.HandlerFunc { - return func(c *gin.Context) { - id := c.Param("uuid") - uuid, err := uuid.Parse(id) - if err != nil{ - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } - fishbone, err := fc.fu.GetByID(uuid) + return func(c *gin.Context) { + id := c.Param("uuid") + fishboneUUID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid UUID format") + return + } - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + fishbone, err := fc.fu.GetByID(fishboneUUID) + if err != nil { + if err.Error() == "fishbone not found" { + common.ErrorResponses(c, http.StatusNotFound, err.Error()) + return + } + common.ErrorResponses(c, http.StatusInternalServerError, err.Error()) + return + } - common.SingleResponses(c, "Success", fishbone) - } + common.SingleResponses(c, "Success", fishbone) + } } func (fc *FishboneController) UpdateFishbone() gin.HandlerFunc { - return func(c *gin.Context) { - id := c.Param("uuid") - uuid, err := uuid.Parse(id) - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + return func(c *gin.Context) { + id := c.Param("uuid") + fishboneUUID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid UUID format") + return + } - var fishboneDTO req.UpdateFishboneDTO - err = c.ShouldBindJSON(&fishboneDTO) + var fishboneDTO req.UpdateFishboneDTO + err = c.ShouldBindJSON(&fishboneDTO) - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } - err = fc.fu.UpdateFishbone(uuid, fishboneDTO) + err = fc.fu.UpdateFishbone(fishboneUUID, fishboneDTO) - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + if err != nil { + if err.Error() == "fishbone not found" { + common.ErrorResponses(c, http.StatusNotFound, err.Error()) + return + } + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } - common.SingleResponses(c, "Fishbone has been updated", nil) - } + common.SingleResponses(c, "Fishbone has been updated successfully", nil) + } +} + +func (fc *FishboneController) DeleteFishbone() gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("uuid") + fishboneUUID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid UUID format") + return + } + + err = fc.fu.DeleteFishbone(fishboneUUID) + if err != nil { + if err.Error() == "fishbone not found" { + common.ErrorResponses(c, http.StatusNotFound, err.Error()) + return + } + common.ErrorResponses(c, http.StatusInternalServerError, err.Error()) + return + } + + common.SingleResponses(c, "Fishbone has been deleted successfully", nil) + } +} + +func (fc *FishboneController) GetFishboneStats() gin.HandlerFunc { + return func(c *gin.Context) { + stats, err := fc.fu.GetFishboneStats() + if err != nil { + common.ErrorResponses(c, http.StatusInternalServerError, err.Error()) + return + } + + common.SingleResponses(c, "Success", stats) + } } \ No newline at end of file diff --git a/delivery/controller/nearest_device_controller.go b/delivery/controller/nearest_device_controller.go new file mode 100644 index 0000000..08986c0 --- /dev/null +++ b/delivery/controller/nearest_device_controller.go @@ -0,0 +1,174 @@ +package controller + +import ( + "net/http" + "strconv" + "users_management/m/config" + "users_management/m/middleware" + "users_management/m/model/dto/req" + "users_management/m/usecase" + "users_management/m/utils/common" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type NearestDeviceController struct { + nearestDeviceUC usecase.NearestDeviceUseCase + rg *gin.RouterGroup + cfg *config.Config +} + +func NewNearestDeviceController(nearestDeviceUC usecase.NearestDeviceUseCase, rg *gin.RouterGroup, cfg *config.Config) *NearestDeviceController { + return &NearestDeviceController{ + nearestDeviceUC: nearestDeviceUC, + rg: rg, + cfg: cfg, + } +} + +func (c *NearestDeviceController) Route() { + nearestDevices := c.rg.Group("/nearest-devices") + nearestDevices.Use(middleware.ConditionalRequireAnyRole(c.cfg,"Teknisi", "Admin", "Super Admin")) + { + nearestDevices.POST("/search", c.getNearestDevices) + nearestDevices.GET("/:id", c.getNearestDeviceByID) + + nearestDevices.POST("/towers/search", c.getNearestTowers) + nearestDevices.GET("/towers/:id", c.getNearestTowerByID) + } +} + +func (c *NearestDeviceController) getNearestTowers(ctx *gin.Context) { + var request req.NearestTowerDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + towers, err := c.nearestDeviceUC.GetNearestTowers(request) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + response := gin.H{ + "towers": towers, + "total": len(towers), + "search_params": gin.H{ + "latitude": request.Latitude, + "longitude": request.Longitude, + "radius": request.Radius, + "province": request.Province, + "city": request.City, + "district": request.District, + }, + } + + common.SingleResponses(ctx, "Nearest towers retrieved successfully", response) +} + +func (c *NearestDeviceController) getNearestTowerByID(ctx *gin.Context) { + id := ctx.Param("id") + towerID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid tower ID") + return + } + + // Get user coordinates from query params + latStr := ctx.Query("lat") + lngStr := ctx.Query("lng") + + if latStr == "" || lngStr == "" { + common.ErrorResponses(ctx, http.StatusBadRequest, "User coordinates (lat, lng) are required") + return + } + + userLat, err := strconv.ParseFloat(latStr, 64) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid latitude") + return + } + + userLng, err := strconv.ParseFloat(lngStr, 64) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid longitude") + return + } + + tower, err := c.nearestDeviceUC.GetNearestTowerByID(towerID, userLat, userLng) + if err != nil { + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + return + } + + common.SingleResponses(ctx, "Tower details retrieved successfully", tower) +} + +func (c *NearestDeviceController) getNearestDevices(ctx *gin.Context) { + var request req.NearestDeviceDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + devices, err := c.nearestDeviceUC.GetNearestDevices(request) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + response := gin.H{ + "devices": devices, + "total": len(devices), + "search_params": gin.H{ + "latitude": request.Latitude, + "longitude": request.Longitude, + "radius": request.Radius, + "province": request.Province, + "city": request.City, + "district": request.District, + }, + } + + common.SingleResponses(ctx, "Nearest devices retrieved successfully", response) +} + +func (c *NearestDeviceController) getNearestDeviceByID(ctx *gin.Context) { + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + // Get user coordinates from query params + latStr := ctx.Query("lat") + lngStr := ctx.Query("lng") + + if latStr == "" || lngStr == "" { + common.ErrorResponses(ctx, http.StatusBadRequest, "User coordinates (lat, lng) are required") + return + } + + userLat, err := strconv.ParseFloat(latStr, 64) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid latitude") + return + } + + userLng, err := strconv.ParseFloat(lngStr, 64) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid longitude") + return + } + + device, err := c.nearestDeviceUC.GetNearestDeviceByID(deviceID, userLat, userLng) + if err != nil { + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + return + } + + common.SingleResponses(ctx, "Device details retrieved successfully", device) +} \ No newline at end of file diff --git a/delivery/controller/olt_controller.go b/delivery/controller/olt_controller.go new file mode 100644 index 0000000..42bec67 --- /dev/null +++ b/delivery/controller/olt_controller.go @@ -0,0 +1,229 @@ +package controller + +import ( + "net/http" + "strconv" + "users_management/m/model/dto/req" + "users_management/m/usecase" + "users_management/m/utils/common" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type OLTController struct { + oltUC usecase.OLTUsecase + rg *gin.RouterGroup +} + +func NewOLTController(oltUC usecase.OLTUsecase, rg *gin.RouterGroup) *OLTController { + return &OLTController{ + oltUC: oltUC, + rg: rg, + } +} + +func (c *OLTController) Route() { + oltGroup := c.rg.Group("/olt") + { + oltGroup.POST("", c.createOLT) + oltGroup.GET("", c.getAllOLTs) + oltGroup.GET("/:id", c.getOLTByID) + oltGroup.PUT("/:id", c.updateOLT) + oltGroup.DELETE("/:id", c.deleteOLT) + oltGroup.POST("/:id/assign-device", c.assignDeviceToOLT) + oltGroup.DELETE("/unassign-device/:deviceId", c.unassignDeviceFromOLT) + oltGroup.GET("/:id/devices", c.getDevicesByOLT) + } +} + +func (c *OLTController) createOLT(ctx *gin.Context) { + var oltDTO req.OLTDTO + if err := ctx.ShouldBindJSON(&oltDTO); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err := c.oltUC.CreateOLT(oltDTO) + if err != nil { + if err.Error() == "OLT name already exists" { + common.ErrorResponses(ctx, http.StatusConflict, err.Error()) + return + } + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + common.SingleResponses(ctx, "OLT created successfully", gin.H{ + "olt_name": oltDTO.OLTName, + }) +} + +func (c *OLTController) getAllOLTs(ctx *gin.Context) { + // Pagination parameters + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + + olts, err := c.oltUC.GetAllOLTs() + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + response := gin.H{ + "olts": olts, + "total": len(olts), + "page": page, + } + + common.SingleResponses(ctx, "OLTs retrieved successfully", response) +} + +func (c *OLTController) getOLTByID(ctx *gin.Context) { + idStr := ctx.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid OLT ID") + return + } + + olt, err := c.oltUC.GetOLTByID(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusNotFound, "OLT not found") + return + } + + common.SingleResponses(ctx, "OLT details retrieved successfully", olt) +} + +func (c *OLTController) updateOLT(ctx *gin.Context) { + idStr := ctx.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid OLT ID") + return + } + + var updateDTO req.UpdateOLTDTO + if err := ctx.ShouldBindJSON(&updateDTO); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err = c.oltUC.UpdateOLT(id, updateDTO) + if err != nil { + switch err.Error() { + case "OLT not found": + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + case "OLT name already exists": + common.ErrorResponses(ctx, http.StatusConflict, err.Error()) + default: + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + } + return + } + + common.SingleResponses(ctx, "OLT updated successfully", gin.H{ + "olt_id": id, + }) +} + +func (c *OLTController) deleteOLT(ctx *gin.Context) { + idStr := ctx.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid OLT ID") + return + } + + err = c.oltUC.DeleteOLT(id) + if err != nil { + if err.Error() == "OLT not found" { + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + return + } + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + common.SingleResponses(ctx, "OLT deleted successfully", gin.H{ + "olt_id": id, + }) +} + +func (c *OLTController) assignDeviceToOLT(ctx *gin.Context) { + oltIDStr := ctx.Param("id") + oltID, err := uuid.Parse(oltIDStr) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid OLT ID") + return + } + + var assignDTO req.AssignDeviceToOLTDTO + if err := ctx.ShouldBindJSON(&assignDTO); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err = c.oltUC.AssignDeviceToOLT(oltID, assignDTO.DeviceID) + if err != nil { + if err.Error() == "only ODP devices can be assigned to OLT" { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + common.SingleResponses(ctx, "Device assigned to OLT successfully", gin.H{ + "olt_id": oltID, + "device_id": assignDTO.DeviceID, + }) +} + +func (c *OLTController) unassignDeviceFromOLT(ctx *gin.Context) { + deviceIDStr := ctx.Param("deviceId") + deviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } + + err = c.oltUC.UnassignDeviceFromOLT(deviceID) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + common.SingleResponses(ctx, "Device unassigned from OLT successfully", gin.H{ + "device_id": deviceID, + }) +} + +func (c *OLTController) getDevicesByOLT(ctx *gin.Context) { + idStr := ctx.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid OLT ID") + return + } + + devices, err := c.oltUC.GetDevicesByOLT(id) + if err != nil { + if err.Error() == "OLT not found" { + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + return + } + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + response := gin.H{ + "devices": devices, + "total": len(devices), + } + + common.SingleResponses(ctx, "OLT devices retrieved successfully", response) +} \ No newline at end of file diff --git a/delivery/controller/tower_controller.go b/delivery/controller/tower_controller.go index c8b6332..e9cddb3 100644 --- a/delivery/controller/tower_controller.go +++ b/delivery/controller/tower_controller.go @@ -1,7 +1,13 @@ package controller import ( + "encoding/json" + "fmt" + "mime/multipart" "net/http" + "strconv" + "strings" + "users_management/m/config" "users_management/m/middleware" "users_management/m/model/dto/req" "users_management/m/usecase" @@ -14,28 +20,81 @@ import ( type TowerController struct { tu usecase.TowerUseCase rg *gin.RouterGroup + cfg *config.Config } func (tc *TowerController) Route() { rg := tc.rg.Group("/tower") - rg.Use(middleware.AuthMiddleware()) - rg.Use(middleware.RateLimitMiddleware()) - rg.Use(middleware.CORSMiddleware()) + rg.Use(middleware.ConditionalRequireAnyRole(tc.cfg,"Teknisi", "Admin","Super Admin")) // Apply middleware to all routes { rg.GET("", tc.GetTower()) rg.POST("", tc.CreateTower()) + rg.GET("/:uuid", tc.GetTowerByID()) rg.PUT("/:uuid", tc.UpdateTower()) + + rg.POST("/bulk-upload-images", tc.BulkUploadImages()) } } -func NewTowerController(tu usecase.TowerUseCase, rg *gin.RouterGroup) *TowerController { +func NewTowerController(tu usecase.TowerUseCase, rg *gin.RouterGroup, cfg *config.Config) *TowerController { return &TowerController{ tu: tu, rg: rg, + cfg: cfg, } } +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() @@ -50,24 +109,110 @@ func (tc *TowerController) GetTower() gin.HandlerFunc { } func (tc *TowerController) CreateTower() gin.HandlerFunc { - return func(c *gin.Context) { - var towerDTO req.TowerDTO - err := c.ShouldBindJSON(&towerDTO) + return func(c *gin.Context) { - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + contentType := c.GetHeader("Content-Type") + + // Handle JSON request (no images) + if strings.Contains(contentType, "application/json") { + var towerDTO req.TowerDTO + if err := c.ShouldBindJSON(&towerDTO); err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid JSON format: "+err.Error()) + return + } - err = tc.tu.Post(towerDTO) + // No image files for JSON requests + err := tc.tu.Post(towerDTO, nil) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + common.SingleResponses(c, "Tower has been created", nil) + return + } + + // Parse multipart form + 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 + } - common.SingleResponses(c, "Tower has been created", nil) - } + // Extract form data + deviceIDStr := c.PostForm("dev_id") + towerCode := c.PostForm("tower_code") + longitudeStr := c.PostForm("longitude") + latitudeStr := c.PostForm("latitude") + externalTowerStr := c.PostForm("external_tower") + + // Validate required fields + if towerCode == "" || longitudeStr == "" || latitudeStr == "" { + common.ErrorResponses(c, http.StatusBadRequest, "Missing required fields") + return + } + + var deviceID *uuid.UUID + if deviceIDStr != "" { + parsedDeviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid device ID") + return + } + deviceID = &parsedDeviceID + } + + // 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 + } + + var externalTower *bool + if externalTowerStr != "" { + parsedExternalTower, err := strconv.ParseBool(externalTowerStr) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid external_tower value") + return + } + externalTower = &parsedExternalTower + } + + // Create DTO + towerDTO := req.TowerDTO{ + DeviceID: deviceID, + TowerCode: towerCode, + Longitude: longitude, + Latitude: latitude, + ExternalTower: externalTower, + } + + // 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.PostWithMultipleImages(towerDTO, imageFiles) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(c, "Tower has been created", nil) + } } func (tc *TowerController) GetTowerByID() gin.HandlerFunc { @@ -90,27 +235,113 @@ func (tc *TowerController) GetTowerByID() gin.HandlerFunc { } func (tc *TowerController) UpdateTower() gin.HandlerFunc { - return func(c *gin.Context) { - id := c.Param("uuid") - uuid, err := uuid.Parse(id) - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - } - var towerUpdateDTO req.UpdateTowerDTO - err = c.ShouldBindJSON(&towerUpdateDTO) + return func(c *gin.Context) { + id := c.Param("uuid") + + tower_uuid, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(c, http.StatusBadRequest, err.Error()) + return + } - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + contentType := c.GetHeader("Content-Type") + + // Handle JSON request (no images) + if strings.Contains(contentType, "application/json") { + var towerUpdateDTO req.UpdateTowerDTO + if err := c.ShouldBindJSON(&towerUpdateDTO); err != nil { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid JSON format: "+err.Error()) + return + } - err = tc.tu.UpdateTower(uuid, towerUpdateDTO) + // 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 + } - if err != nil { - common.ErrorResponses(c, http.StatusBadRequest, err.Error()) - return - } + common.SingleResponses(c, "Tower has been updated", nil) + return + } - common.SingleResponses(c, "Tower has been updated", nil) - } + // 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 + } + + // Create update DTO + towerUpdateDTO := req.UpdateTowerDTO{} + + // Optional fields - handle form data + if deviceIDStr := c.PostForm("device_id"); deviceIDStr != "" { + if deviceID, err := uuid.Parse(deviceIDStr); err == nil { + towerUpdateDTO.DeviceID = &deviceID + } else { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid device ID format") + return + } + } + + if towerCode := c.PostForm("tower_code"); towerCode != "" { + towerUpdateDTO.TowerCode = &towerCode + } + + if longitudeStr := c.PostForm("longitude"); longitudeStr != "" { + if longitude, err := strconv.ParseFloat(longitudeStr, 64); err == nil { + towerUpdateDTO.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 { + towerUpdateDTO.Latitude = &latitude + } else { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid latitude format") + return + } + } + + // Handle external_tower field + if externalTowerStr := c.PostForm("external_tower"); externalTowerStr != "" { + if externalTower, err := strconv.ParseBool(externalTowerStr); err == nil { + towerUpdateDTO.ExternalTower = &externalTower + } else { + common.ErrorResponses(c, http.StatusBadRequest, "Invalid external_tower value") + return + } + } + + // Handle image_url field (for updating image URL directly) + if imageURL := c.PostForm("image_url"); imageURL != "" { + towerUpdateDTO.ImageURL = &imageURL + } + + // 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 - 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 + } + + common.SingleResponses(c, "Tower has been updated", nil) + } } \ No newline at end of file diff --git a/delivery/controller/user_management_controller.go b/delivery/controller/user_management_controller.go new file mode 100644 index 0000000..a3638b8 --- /dev/null +++ b/delivery/controller/user_management_controller.go @@ -0,0 +1,263 @@ +package controller + +import ( + "errors" + "net/http" + "users_management/m/middleware" + "users_management/m/usecase" + "users_management/m/utils/common" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type UserRoleManagementController struct { + userUC usecase.UsersUsecase + rg *gin.RouterGroup +} + +func NewUserRoleManagementController(userUC usecase.UsersUsecase, rg *gin.RouterGroup) *UserRoleManagementController { + return &UserRoleManagementController{ + userUC: userUC, + rg: rg, + } +} + +func (c *UserRoleManagementController) Route() { + userRole := c.rg.Group("/user-role-management") + userRole.Use(middleware.RequireAdminRole()) // Only Admin and Super Admin can access + { + userRole.PUT("/update/:id", c.updateUserRoleByID) + userRole.PUT("/update-by-username", c.updateUserRoleByUsername) + userRole.GET("/users", c.getAllUsersWithRoles) + userRole.GET("/available-roles", c.getAvailableRoles) + } +} + +type UpdateUserRoleByIDRequest struct { + NewRole string `json:"new_role" binding:"required,oneof=Teknisi Admin"` +} + +type UpdateUserRoleByUsernameRequest struct { + Username string `json:"username" binding:"required"` + NewRole string `json:"new_role" binding:"required,oneof=Teknisi Admin"` +} + +func (c *UserRoleManagementController) updateUserRoleByID(ctx *gin.Context) { + // Get user ID from URL parameter + userIDStr := ctx.Param("id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid user ID format") + return + } + + var req UpdateUserRoleByIDRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + // Get current user role to check permissions + currentUserRole, exists := ctx.Get("userRole") + if !exists { + common.ErrorResponses(ctx, http.StatusUnauthorized, "User role not found") + return + } + + currentUserRoleStr, ok := currentUserRole.(string) + if !ok { + common.ErrorResponses(ctx, http.StatusUnauthorized, "Invalid user role format") + return + } + + // Get target user to check current role and prevent invalid operations + targetUser, err := c.userUC.GetUserByID(userID) + if err != nil { + common.ErrorResponses(ctx, http.StatusNotFound, "User not found") + return + } + + // Validate role change permissions + if err := c.validateRoleChange(currentUserRoleStr, targetUser.Role.Name, req.NewRole); err != nil { + common.ErrorResponses(ctx, http.StatusForbidden, err.Error()) + return + } + + // Update user role + err = c.userUC.UpdateUserRoleByID(userID, req.NewRole) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, "Failed to update user role: "+err.Error()) + return + } + + // Get updated user info + updatedUser, err := c.userUC.GetUserByID(userID) + if err != nil { + // Role was updated but couldn't fetch updated info + common.SingleResponses(ctx, "User role updated successfully", gin.H{ + "user_id": userID, + "new_role": req.NewRole, + }) + return + } + + response := gin.H{ + "user_id": updatedUser.ID, + "username": updatedUser.Username, + "name": updatedUser.Name, + "previous_role": targetUser.Role.Name, + "new_role": updatedUser.Role.Name, + "updated_by": ctx.GetString("userName"), + } + + common.SingleResponses(ctx, "User role updated successfully", response) +} + +func (c *UserRoleManagementController) updateUserRoleByUsername(ctx *gin.Context) { + var req UpdateUserRoleByUsernameRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + // Get current user role to check permissions + currentUserRole, exists := ctx.Get("userRole") + if !exists { + common.ErrorResponses(ctx, http.StatusUnauthorized, "User role not found") + return + } + + currentUserRoleStr, ok := currentUserRole.(string) + if !ok { + common.ErrorResponses(ctx, http.StatusUnauthorized, "Invalid user role format") + return + } + + // Get target user + targetUser, err := c.userUC.GetUserByUsernameWithStatus(req.Username) + if err != nil { + common.ErrorResponses(ctx, http.StatusNotFound, "User not found") + return + } + + // Validate role change permissions + if err := c.validateRoleChange(currentUserRoleStr, targetUser.Role.Name, req.NewRole); err != nil { + common.ErrorResponses(ctx, http.StatusForbidden, err.Error()) + return + } + + // Update user role by username + err = c.userUC.UpdateUserRoleByUsername(req.Username, req.NewRole) + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, "Failed to update user role: "+err.Error()) + return + } + + // Get updated user info + updatedUser, err := c.userUC.GetUserByUsernameWithStatus(req.Username) + if err != nil { + // Role was updated but couldn't fetch updated info + common.SingleResponses(ctx, "User role updated successfully", gin.H{ + "username": req.Username, + "new_role": req.NewRole, + }) + return + } + + response := gin.H{ + "user_id": updatedUser.ID, + "username": updatedUser.Username, + "name": updatedUser.Name, + "previous_role": targetUser.Role.Name, + "new_role": updatedUser.Role.Name, + "updated_by": ctx.GetString("userName"), + } + + common.SingleResponses(ctx, "User role updated successfully", response) +} + +func (c *UserRoleManagementController) getAllUsersWithRoles(ctx *gin.Context) { + users, err := c.userUC.GetAllUsers() + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, "Failed to get users: "+err.Error()) + return + } + + // Filter and format response + var userList []gin.H + for _, user := range users { + // Only show approved users + if user.Status == "approved" { + userInfo := gin.H{ + "id": user.ID, + "username": user.Username, + "name": user.Name, + "nomor_induk": user.NomorInduk, + "role": user.Role.Name, + "status": user.Status, + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + } + userList = append(userList, userInfo) + } + } + + response := gin.H{ + "users": userList, + "total": len(userList), + } + + common.SingleResponses(ctx, "Users retrieved successfully", response) +} + +func (c *UserRoleManagementController) getAvailableRoles(ctx *gin.Context) { + roles := []gin.H{ + { + "name": "Teknisi", + "description": "Technical user with limited administrative access", + "can_be_assigned_by": []string{"Admin", "Super Admin"}, + }, + { + "name": "Admin", + "description": "Administrative user with full system access except user management", + "can_be_assigned_by": []string{"Admin", "Super Admin"}, + }, + } + + currentUserRole := ctx.GetString("userRole") + + response := gin.H{ + "available_roles": roles, + "your_role": currentUserRole, + "note": "You can update users between Teknisi and Admin roles only", + } + + common.SingleResponses(ctx, "Available roles for assignment", response) +} + +// Helper function to validate role change permissions +func (c *UserRoleManagementController) validateRoleChange(currentUserRole, targetCurrentRole, newRole string) error { + // Super Admin can change any role to Teknisi or Admin + if currentUserRole == "Super Admin" { + return nil + } + + // Admin can change roles but with some restrictions + if currentUserRole == "Admin" { + // Admin cannot change Super Admin roles + if targetCurrentRole == "Super Admin" { + return errors.New("cannot modify Super Admin users") + } + + // Admin cannot assign Super Admin role + if newRole == "Super Admin" { + return errors.New("cannot assign Super Admin role") + } + + return nil + } + + // Only Admin and Super Admin can change roles + return errors.New("insufficient permissions to change user roles") +} \ No newline at end of file diff --git a/delivery/controller/user_registration.go b/delivery/controller/user_registration.go new file mode 100644 index 0000000..2bfbf33 --- /dev/null +++ b/delivery/controller/user_registration.go @@ -0,0 +1,128 @@ +package controller + +import ( + "net/http" + "users_management/m/model/dto" + "users_management/m/usecase" + "users_management/m/utils/common" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type UserRegistrationController struct { + userUC usecase.UsersUsecase + rg *gin.RouterGroup +} + +func NewUserRegistrationController(userUC usecase.UsersUsecase, rg *gin.RouterGroup) *UserRegistrationController { + return &UserRegistrationController{ + userUC: userUC, + rg: rg, + } +} + +func (c *UserRegistrationController) Route() { + registration := c.rg.Group("/user-registration") + { + // Public registration endpoint + registration.POST("/register", c.registerUser) + } + + adminRegistration := c.rg.Group("/user-registration") + adminRegistration.GET("/pending", c.getPendingUsers) + adminRegistration.PUT("/approve/:id", c.approveUser) + adminRegistration.PUT("/reject/:id", c.rejectUser) +} + +func (c *UserRegistrationController) RegisterUser(ctx *gin.Context) { + c.registerUser(ctx) +} + +func (c *UserRegistrationController) GetPendingUsers(ctx *gin.Context) { + c.getPendingUsers(ctx) +} + +func (c *UserRegistrationController) ApproveUser(ctx *gin.Context) { + c.approveUser(ctx) +} + +func (c *UserRegistrationController) RejectUser(ctx *gin.Context) { + c.rejectUser(ctx) +} + +func (c *UserRegistrationController) registerUser(ctx *gin.Context) { + var registerDTO dto.UserRegisterDTO + if err := ctx.ShouldBindJSON(®isterDTO); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + err := c.userUC.RegisterUser(registerDTO) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + response := gin.H{ + "message": "Registration successful. Your account is pending approval from an administrator.", + "status": "pending", + "next_steps": "Please wait for an administrator to approve your account before you can log in.", + } + + common.SingleResponses(ctx, "User registered successfully", response) +} + +func (c *UserRegistrationController) getPendingUsers(ctx *gin.Context) { + + users, err := c.userUC.GetPendingUsers() + if err != nil { + common.ErrorResponses(ctx, http.StatusInternalServerError, err.Error()) + return + } + + response := gin.H{ + "users": users, + "total": len(users), + } + + common.SingleResponses(ctx, "Pending users retrieved successfully", response) +} + +func (c *UserRegistrationController) approveUser(ctx *gin.Context) { + userID, err := uuid.Parse(ctx.Param("id")) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid user ID") + return + } + + err = c.userUC.ApproveUser(userID) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "User approved successfully", gin.H{ + "user_id": userID, + "status": "approved", + }) +} + +func (c *UserRegistrationController) rejectUser(ctx *gin.Context) { + userID, err := uuid.Parse(ctx.Param("id")) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid user ID") + return + } + + err = c.userUC.RejectUser(userID) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + common.SingleResponses(ctx, "User rejected successfully", gin.H{ + "user_id": userID, + "status": "rejected", + }) +} \ No newline at end of file diff --git a/delivery/controller/users_controller.go b/delivery/controller/users_controller.go index 99b9320..5236ff4 100644 --- a/delivery/controller/users_controller.go +++ b/delivery/controller/users_controller.go @@ -19,7 +19,7 @@ type UsersController struct { func (uc *UsersController) Route() { rg:= uc.rg.Group("/users") rg.Use(middleware.CORSMiddleware()) - rg.Use(middleware.RateLoginMiddleware()) + // rg.Use(middleware.RateLoginMiddleware()) rg.OPTIONS("/login", func(c *gin.Context) { c.Status(http.StatusNoContent) }) diff --git a/delivery/server.go b/delivery/server.go index b6b0afa..8ce1aaa 100644 --- a/delivery/server.go +++ b/delivery/server.go @@ -15,6 +15,7 @@ type Server struct { ucManager manager.UsecaseManager engine *gin.Engine host string + cfg *config.Config } func NewServer() *Server { @@ -31,6 +32,10 @@ func NewServer() *Server { repoManager := manager.NewRepositoryManager(infraManager) ucManager := manager.NewUsecaseManager(repoManager, cfg) engine := gin.Default() + + engine.Use(middleware.CORSMiddleware()) + engine.Use(middleware.ConditionalActivityLoggingMiddleware(ucManager.NewActivityLogUsecase(), cfg)) + engine.Use(common.LoggingToFile()) host := fmt.Sprintf(":%s", cfg.ApiPort) @@ -38,22 +43,61 @@ func NewServer() *Server { ucManager: ucManager, engine: engine, host: host, + cfg: cfg, } } 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.engine.Static("/uploads", "./uploads") + + rg := s.engine.Group("/api/v1") + + // Add bypass controller FIRST - before any auth middleware + controller.NewBypassController(s.ucManager.NewUserUsecase(), rg).Route() + + // Add auth controller (public endpoints) + controller.NewAuthController(s.ucManager.NewAuthUsecase(), rg).Route() + + publicRegistration := rg.Group("/user-registration") + { + publicRegistration.POST("/register", controller.NewUserRegistrationController(s.ucManager.NewUserUsecase(), rg).RegisterUser) + } + + + + // Add user registration controller (public endpoints) + // controller.NewUserRegistrationController(s.ucManager.NewUserUsecase(), rg).Route() + + // Now apply auth middleware to protected routes + protected := rg.Use(middleware.ConditionalAuthMiddleware(s.ucManager.NewUserUsecase(), s.ucManager.NewAuthUsecase(), s.cfg)).(*gin.RouterGroup) { - controller.NewDeviceController(s.ucManager.NewDeviceUsecase(), rg).Route() - controller.NewBackboneController(s.ucManager.NewBackboneUsecase(), rg).Route() - controller.NewFishboneController(s.ucManager.NewFishboneUsecase(), rg).Route() - controller.NewTowerController(s.ucManager.NewTowerUsecase(), rg).Route() - controller.NewDevicePortController(s.ucManager.NewDevicePortUsecase(), rg).Route() - } + protectedRegistration := protected.Group("/user-registration") + { + protectedRegistration.GET("/pending", middleware.RequireAdminRole(), controller.NewUserRegistrationController(s.ucManager.NewUserUsecase(), protected).GetPendingUsers) + protectedRegistration.PUT("/approve/:id", middleware.RequireAdminRole(), controller.NewUserRegistrationController(s.ucManager.NewUserUsecase(), protected).ApproveUser) + protectedRegistration.PUT("/reject/:id", middleware.RequireAdminRole(), controller.NewUserRegistrationController(s.ucManager.NewUserUsecase(), protected).RejectUser) + } + controller.NewUserRoleManagementController(s.ucManager.NewUserUsecase(), protected).Route() + controller.NewUsersController(s.ucManager.NewUserUsecase(), s.ucManager.NewAuthUsecase(), protected).Route() + controller.NewCountAssetsController(s.ucManager.NewCountAssetsUsecase(), protected).Route() + controller.NewDeviceController(s.ucManager.NewDeviceUsecase(), protected, s.cfg).Route() + controller.NewBackboneController(s.ucManager.NewBackboneUsecase(), protected, s.cfg).Route() + controller.NewFishboneController(s.ucManager.NewFishboneUsecase(), protected, s.cfg).Route() + controller.NewTowerController(s.ucManager.NewTowerUsecase(), protected, s.cfg).Route() + controller.NewDevicePortController(s.ucManager.NewDevicePortUsecase(), protected).Route() + controller.NewActivityLogController(s.ucManager.NewActivityLogUsecase(), protected, s.cfg).Route() + controller.NewDeviceInspectionController(s.ucManager.NewDeviceInspectionUsecase(), protected).Route() + controller.NewNearestDeviceController(s.ucManager.NewNearestDeviceUsecase(), protected, s.cfg).Route() + controller.NewDeviceDetailsController(s.ucManager.NewDeviceDetailsUsecase(), protected, s.cfg).Route() + controller.NewOLTController(s.ucManager.NewOLTUsecase(), protected).Route() + + } + } + + func (s *Server) Run() { s.setupController() if err := s.engine.Run(s.host); err != nil { diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 4c73ec3..c05424a 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,5 +1,5 @@ # Use the official Golang image -FROM golang:1.23.1 +FROM golang:1.23.1-alpine # Set the working directory inside the container WORKDIR /app diff --git a/dev-nam b/dev-nam new file mode 100755 index 0000000..3b45300 Binary files /dev/null and b/dev-nam differ diff --git a/go.mod b/go.mod index c9385cf..c8cc8ad 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,11 @@ go 1.23.1 require ( github.com/gin-gonic/gin v1.10.0 github.com/go-playground/validator/v10 v10.24.0 + github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.33.0 + golang.org/x/time v0.10.0 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.25.12 ) @@ -15,7 +18,6 @@ require ( github.com/bytedance/sonic v1.12.8 // indirect github.com/bytedance/sonic/loader v0.2.3 // indirect github.com/cloudwego/base64x v0.1.5 // indirect - github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.0.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -39,12 +41,10 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.14.0 // indirect - golang.org/x/crypto v0.33.0 // indirect golang.org/x/net v0.35.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect - golang.org/x/time v0.10.0 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b5f466a..4671253 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= @@ -28,6 +26,8 @@ github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/manager/infra_manager.go b/manager/infra_manager.go index 6ac151a..835c67f 100644 --- a/manager/infra_manager.go +++ b/manager/infra_manager.go @@ -3,6 +3,8 @@ package manager import ( "fmt" "users_management/m/config" + "users_management/m/model/entity" + "gorm.io/driver/postgres" "gorm.io/gorm" @@ -24,10 +26,31 @@ func (im *infraManager) openConn() error { if err != nil { return err } + + err = im.autoMigrate(db) + if err != nil { + return fmt.Errorf("failed to migrate database schema: %w", err) + } + im.db = db return nil } +func (im *infraManager) autoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + &entity.Role{}, + &entity.User{}, + &entity.Device{}, + &entity.Backbone{}, + &entity.Fishbone{}, + &entity.Tower{}, + &entity.DevicePort{}, + &entity.CountAssets{}, + &entity.ActivityLog{}, + &entity.OLT{}, + ) +} + func NewInfraManager(cfg *config.Config) (InfraManager, error) { im := &infraManager{ cfg: cfg, diff --git a/manager/repository_manager.go b/manager/repository_manager.go index 283e91e..3f9d065 100644 --- a/manager/repository_manager.go +++ b/manager/repository_manager.go @@ -10,6 +10,14 @@ type RepositoryManager interface { NewTowerRepository() repository.TowerRepo NewDevicePortRepository() repository.DevicePortRepo + NewCountAssetsRepository() repository.CountAssetsRepo + NewActivityLogRepository() repository.ActivityLogRepo + NewDeviceInspectionRepository() repository.DeviceInspectionRepo + NewNearestDeviceRepository() repository.NearestDeviceRepo + + NewDeviceDetailsRepository() repository.DeviceDetailsRepo + NewOLTRepo() repository.OLTRepo + } type repositoryManager struct { @@ -22,6 +30,13 @@ func NewRepositoryManager(infra InfraManager) RepositoryManager { } } +func (rm *repositoryManager) NewDeviceDetailsRepository() repository.DeviceDetailsRepo { + return repository.NewDeviceDetailsRepo(rm.infra.Conn()) +} +func (rm *repositoryManager) NewNearestDeviceRepository() repository.NearestDeviceRepo { + return repository.NewNearestDeviceRepo(rm.infra.Conn()) +} + func (rm *repositoryManager) NewUserRepository() repository.UsersRepo { return repository.NewUsersRepo(rm.infra.Conn()) } @@ -44,4 +59,20 @@ func (rm *repositoryManager) NewTowerRepository() repository.TowerRepo { func (rm *repositoryManager) NewDevicePortRepository() repository.DevicePortRepo { return repository.NewDevicePortRepo(rm.infra.Conn()) +} + +func (rm *repositoryManager) NewCountAssetsRepository() repository.CountAssetsRepo { + return repository.NewCountAssetsRepo(rm.infra.Conn()) +} + +func (rm *repositoryManager) NewActivityLogRepository() repository.ActivityLogRepo { + return repository.NewActivityLogRepo(rm.infra.Conn()) +} + +func (rm *repositoryManager) NewDeviceInspectionRepository() repository.DeviceInspectionRepo { + return repository.NewDeviceInspectionRepo(rm.infra.Conn()) +} + +func (rm *repositoryManager) NewOLTRepo() repository.OLTRepo { + return repository.NewOLTRepo(rm.infra.Conn()) } \ No newline at end of file diff --git a/manager/usecase_manager.go b/manager/usecase_manager.go index 4051557..7dcd23b 100644 --- a/manager/usecase_manager.go +++ b/manager/usecase_manager.go @@ -3,6 +3,7 @@ package manager import ( "users_management/m/config" "users_management/m/usecase" + "users_management/m/utils/service" ) type UsecaseManager interface { @@ -15,15 +16,44 @@ type UsecaseManager interface { NewTowerUsecase() usecase.TowerUseCase NewDevicePortUsecase() usecase.DevicePortUseCase + NewCountAssetsUsecase() usecase.CountAssetsUseCase + + NewActivityLogUsecase() usecase.ActivityLogUseCase + + NewDeviceInspectionUsecase() usecase.DeviceInspectionUseCase + + NewNearestDeviceUsecase() usecase.NearestDeviceUseCase + + NewDeviceDetailsUsecase() usecase.DeviceDetailsUseCase + NewOLTUsecase() usecase.OLTUsecase + } type usecaseManager struct { repo RepositoryManager + geocoder service.GeocodingService cfg *config.Config } func NewUsecaseManager(repo RepositoryManager, cfg *config.Config) UsecaseManager { - return &usecaseManager{repo: repo,cfg: cfg } + basegeoCoder := service.NewGeocodingService() + cachedGeocoder := service.NewCachedGeocodingService(basegeoCoder) + return &usecaseManager{repo: repo,cfg: cfg , geocoder: cachedGeocoder} +} + +func (um *usecaseManager) NewDeviceDetailsUsecase() usecase.DeviceDetailsUseCase { + return usecase.NewDeviceDetailsUseCase( + um.repo.NewDeviceDetailsRepository(), + um.repo.NewOLTRepo(), + um.geocoder, + ) +} + +func (um *usecaseManager) NewNearestDeviceUsecase() usecase.NearestDeviceUseCase { + return usecase.NewNearestDeviceUseCase( + um.repo.NewNearestDeviceRepository(), + um.geocoder, + ) } func (um *usecaseManager) NewUserUsecase() usecase.UsersUsecase { @@ -35,21 +65,51 @@ func (um *usecaseManager) NewAuthUsecase() usecase.AuthUsecase { } func (um *usecaseManager) NewDeviceUsecase() usecase.DeviceUseCase { - return usecase.NewDeviceUseCase(um.repo.NewDeviceRepository()) + return usecase.NewDeviceUseCase(um.repo.NewDeviceRepository(), um.repo.NewOLTRepo(), um.geocoder) } func (um *usecaseManager) NewBackboneUsecase() usecase.BackboneUseCase { - return usecase.NewBackboneUseCase(um.repo.NewBackboneRepository()) + return usecase.NewBackboneUseCase( + um.repo.NewBackboneRepository(), + um.repo.NewFishboneRepository(), + um.repo.NewDeviceDetailsRepository(), // Add this + ) } + func (um *usecaseManager) NewFishboneUsecase() usecase.FishboneUseCase { - return usecase.NewFishboneUseCase(um.repo.NewFishboneRepository()) + return usecase.NewFishboneUseCase( + um.repo.NewFishboneRepository(), + um.repo.NewBackboneRepository(), + um.repo.NewDeviceDetailsRepository(), // Add this line + ) } func (um *usecaseManager) NewTowerUsecase() usecase.TowerUseCase { - return usecase.NewTowerUseCase(um.repo.NewTowerRepository()) + return usecase.NewTowerUseCase(um.repo.NewTowerRepository(), um.geocoder) } func (um *usecaseManager) NewDevicePortUsecase() usecase.DevicePortUseCase { return usecase.NewDevicePortUseCase(um.repo.NewDevicePortRepository()) +} + + +func (um *usecaseManager) NewCountAssetsUsecase() usecase.CountAssetsUseCase { + return usecase.NewCountAssetsUseCase(um.repo.NewCountAssetsRepository()) +} + +func (um *usecaseManager) NewActivityLogUsecase() usecase.ActivityLogUseCase { + return usecase.NewActivityLogUseCase(um.repo.NewActivityLogRepository()) +} + +func (um *usecaseManager) NewDeviceInspectionUsecase() usecase.DeviceInspectionUseCase { + return usecase.NewDeviceInspectionUseCase( + um.repo.NewDeviceInspectionRepository(), + um.NewActivityLogUsecase(), + service.NewGeocodingService(), // Add the geocoding service + ) +} + +func (um *usecaseManager) NewOLTUsecase() usecase.OLTUsecase { + return usecase.NewOLTUsecase(um.repo.NewOLTRepo(), um.repo.NewDeviceDetailsRepository(), service.NewGeocodingService()) } \ No newline at end of file diff --git a/middleware/activity_logs_middleware.go b/middleware/activity_logs_middleware.go new file mode 100644 index 0000000..e28f4b3 --- /dev/null +++ b/middleware/activity_logs_middleware.go @@ -0,0 +1,155 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "users_management/m/config" + "users_management/m/usecase" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type responseWriter struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (r responseWriter) Write(b []byte) (int, error) { + r.body.Write(b) + return r.ResponseWriter.Write(b) +} + +// ConditionalActivityLoggingMiddleware that respects auth config +func ConditionalActivityLoggingMiddleware(activityLogUC usecase.ActivityLogUseCase, cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + // If auth is disabled, skip activity logging + if !cfg.AuthConfig.UserAuthEnabled { + c.Next() + return + } + + // If auth is enabled, run normal activity logging + ActivityLoggingMiddleware(activityLogUC)(c) + } +} +func ActivityLoggingMiddleware(activityLogUC usecase.ActivityLogUseCase) gin.HandlerFunc { + return func(c *gin.Context) { + // Skip logging for certain endpoints + if shouldSkipLogging(c.Request.URL.Path) { + c.Next() + return + } + + // Get user info from context (set by auth middleware) + userID, userExists := c.Get("userID") + if !userExists { + c.Next() + return + } + + // Convert userID to UUID + uid, ok := userID.(uuid.UUID) + if !ok { + c.Next() + return + } + + // Read request body + var requestBody []byte + if c.Request.Body != nil { + requestBody, _ = io.ReadAll(c.Request.Body) + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + } + + // Wrap response writer to capture response + w := &responseWriter{body: &bytes.Buffer{}, ResponseWriter: c.Writer} + c.Writer = w + + // Process request + c.Next() + + // Log the activity in background + go func() { + action := getActionFromMethod(c.Request.Method) + resource := getResourceFromPath(c.Request.URL.Path) + resourceID := getResourceIDFromPath(c.Request.URL.Path) + + var oldData, newData interface{} + + // For updates, you might want to fetch old data before the operation + // This is a simplified version + if len(requestBody) > 0 { + json.Unmarshal(requestBody, &newData) + } + + details := "" + if c.Writer.Status() >= 400 { + details = fmt.Sprintf("Request failed with status: %d", c.Writer.Status()) + } + + activityLogUC.LogActivity( + uid, + action, + resource, + resourceID, + oldData, + newData, + c.ClientIP(), + c.Request.UserAgent(), + details, + ) + }() + } +} + +func shouldSkipLogging(path string) bool { + skipPaths := []string{ + "/api/v1/logs", + "/api/v1/users/login", + "/api/v1/users/logout", + "/uploads", + } + + for _, skipPath := range skipPaths { + if strings.HasPrefix(path, skipPath) { + return true + } + } + return false +} + +func getActionFromMethod(method string) string { + switch method { + case "POST": + return "CREATE" + case "PUT", "PATCH": + return "UPDATE" + case "DELETE": + return "DELETE" + case "GET": + return "READ" + default: + return method + } +} + +func getResourceFromPath(path string) string { + parts := strings.Split(path, "/") + if len(parts) >= 4 { + return parts[3] // /api/v1/devices -> devices + } + return "unknown" +} + +func getResourceIDFromPath(path string) *string { + parts := strings.Split(path, "/") + if len(parts) >= 5 { + id := parts[4] + return &id + } + return nil +} \ No newline at end of file diff --git a/middleware/auth_middleware.go b/middleware/auth_middleware.go index beaa0dd..6ee78f0 100644 --- a/middleware/auth_middleware.go +++ b/middleware/auth_middleware.go @@ -1,67 +1,95 @@ package middleware import ( - "encoding/json" - "net/http" - "strings" + "log" + "net/http" + "strings" - "users_management/m/model/dto/res" - "users_management/m/utils/common" + "users_management/m/config" + "users_management/m/model/entity" + "users_management/m/usecase" + "users_management/m/utils" + "users_management/m/utils/common" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" + "github.com/google/uuid" ) -func AuthMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - token := c.GetHeader("Authorization") +// ConditionalAuthMiddleware checks config to decide whether to apply auth +func ConditionalAuthMiddleware(userUC usecase.UsersUsecase, authUC usecase.AuthUsecase, cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + // If auth is disabled, skip authentication but set default values + if !cfg.AuthConfig.UserAuthEnabled { + log.Println("Authentication disabled - skipping auth middleware") + + // Set default values for when auth is disabled + c.Set("userID", uuid.New()) // Generate a dummy UUID + c.Set("userName", "system") + c.Set("userRole", "Admin") // Default role when auth is disabled + c.Set("token", "no-auth-mode") + + c.Next() + return + } - if token == "" { - common.ErrorResponses(c, http.StatusUnauthorized, "authorization token required") - c.Abort() - return - } + // If auth is enabled, run the local auth middleware + LocalAuthMiddleware(userUC, authUC)(c) + } +} - token = strings.TrimPrefix(token, "Bearer ") +// LocalAuthMiddleware handles local JWT authentication +func LocalAuthMiddleware(userUC usecase.UsersUsecase, authUC usecase.AuthUsecase) gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("Authorization") - c.Set("token", token) - - req, err := http.NewRequest("POST", "https://demo.api-hrm.winteraccess.id/api/v2/auth/me", nil) - - if err != nil { - common.ErrorResponses(c, http.StatusInternalServerError, err.Error()) - c.Abort() - return - } - - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/json") - - client := &http.Client{} - - resp, err := client.Do(req) - if err != nil { - common.ErrorResponses(c, http.StatusInternalServerError, err.Error()) - c.Abort() - return - } - - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - common.ErrorResponses(c, http.StatusUnauthorized, "Unauthroized") - c.Abort() - return - } - - var authResponse res.AuthMeResponse - if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil { - common.ErrorResponses(c, http.StatusInternalServerError, err.Error()) + if token == "" { + common.ErrorResponses(c, http.StatusUnauthorized, "Authorization token required") c.Abort() return } - c.Set("userID", authResponse.Data.NomorInduk) + token = strings.TrimPrefix(token, "Bearer ") + c.Set("token", token) - c.Next() + // Validate JWT token locally + _, username, err := utils.ValidateJWT(token) + if err != nil { + log.Printf("JWT validation error: %v", err) + common.ErrorResponses(c, http.StatusUnauthorized, "Invalid or expired token") + c.Abort() + return + } - } -} + // Get user from local database + user, err := userUC.GetUserByUsername(username) + if err != nil { + log.Printf("User not found: %s", username) + common.ErrorResponses(c, http.StatusUnauthorized, "User not found") + c.Abort() + return + } + + // Check if user is approved + switch user.Status { + case entity.UserStatusPending: + common.ErrorResponses(c, http.StatusForbidden, "Your account is pending approval from an administrator. Please wait for approval before accessing the system.") + c.Abort() + return + case entity.UserStatusRejected: + common.ErrorResponses(c, http.StatusForbidden, "Your account has been rejected. Please contact an administrator for more information.") + c.Abort() + return + case entity.UserStatusApproved: + // User is approved, continue with normal flow + c.Set("userID", user.ID) + c.Set("userName", user.Username) + c.Set("userRole", user.Role.Name) + default: + common.ErrorResponses(c, http.StatusForbidden, "Your account status is invalid. Please contact an administrator.") + c.Abort() + return + } + + c.Next() + } +} \ No newline at end of file diff --git a/middleware/cors_middleware.go b/middleware/cors_middleware.go index a1ba6dc..fe387bc 100644 --- a/middleware/cors_middleware.go +++ b/middleware/cors_middleware.go @@ -1,23 +1,66 @@ package middleware import ( - "net/http" - - "github.com/gin-gonic/gin" + "net/http" + "strings" + "github.com/gin-gonic/gin" ) func CORSMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") // Change to specific domains if needed - c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + return func(c *gin.Context) { + origin := c.Request.Header.Get("Origin") + + // Define allowed origins (add your React app's URL) + allowedOrigins := []string{ + "http://localhost:3000", + "http://localhost:3001", + "http://127.0.0.1:3000", + "http://127.0.0.1:5173", + "http://103.110.8.103:80",// Add production URL + "http://103.110.8.103", - // Allow OPTIONS method to pass through - if c.Request.Method == http.MethodOptions { - c.AbortWithStatus(http.StatusNoContent) - return - } + "http://nam.winteraccess.id", - c.Next() - } -} \ No newline at end of file + } + + // Check if origin is in allowed list + isAllowed := false + for _, allowed := range allowedOrigins { + if origin == allowed { + isAllowed = true + break + } + } + + // For development, also allow localhost variations + if strings.Contains(origin, "localhost") || strings.Contains(origin, "127.0.0.1") { + isAllowed = true + } + + if origin != "" && isAllowed { + // For allowed origins, set specific origin and enable credentials + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + } else if origin == "" { + // For requests without origin (direct API calls, mobile apps, etc.) + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + // Don't set credentials for wildcard + } else { + // For disallowed origins, still set basic CORS but no credentials + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + } + + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE, PATCH") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type") + c.Writer.Header().Set("Access-Control-Max-Age", "86400") + + // Handle preflight requests + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} diff --git a/middleware/rate_limitter.go b/middleware/rate_limitter.go index 880a306..21219d5 100644 --- a/middleware/rate_limitter.go +++ b/middleware/rate_limitter.go @@ -1,18 +1,20 @@ package middleware import ( - "net/http" - "sync" - "time" - "users_management/m/utils/common" + "log" + "net/http" + "sync" + "time" + "users_management/m/utils/common" - "github.com/gin-gonic/gin" - "golang.org/x/time/rate" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "golang.org/x/time/rate" ) var ( - rateLimiters = make(map[string]*rate.Limiter) - mu sync.Mutex + rateLimiters = make(map[string]*rate.Limiter) + mu sync.Mutex ) func getRateLimiter(userID string) *rate.Limiter { @@ -21,7 +23,7 @@ func getRateLimiter(userID string) *rate.Limiter { limiter, exists := rateLimiters[userID] if !exists { - limiter = rate.NewLimiter(rate.Every(1*time.Minute), 50) // 1 request per second with a burst of 2 requests + limiter = rate.NewLimiter(rate.Every(1*time.Minute), 50) // 50 requests per minute rateLimiters[userID] = limiter } @@ -29,32 +31,49 @@ func getRateLimiter(userID string) *rate.Limiter { } func getLoginLimiter() *rate.Limiter { - mu.Lock() - defer mu.Unlock() + mu.Lock() + defer mu.Unlock() - limiter, exists := rateLimiters["login"] - if !exists { - limiter = rate.NewLimiter(rate.Every(1*time.Minute), 10) // 5 request per second with a burst of 10 requests - rateLimiters["login"] = limiter - } + limiter, exists := rateLimiters["login"] + if !exists { + limiter = rate.NewLimiter(rate.Every(1*time.Minute), 10) // 10 requests per minute for login + rateLimiters["login"] = limiter + } - return limiter + return limiter } func RateLimitMiddleware() gin.HandlerFunc{ - return func(c *gin.Context) { - if c.Request.Method == http.MethodOptions { - c.Next() - return - } - userID, exists := c.Get("userID") + return func(c *gin.Context) { + if c.Request.Method == http.MethodOptions { + c.Next() + return + } + + userID, exists := c.Get("userID") if !exists { common.ErrorResponses(c, http.StatusUnauthorized, "Unauthorized: No user ID found") c.Abort() return } - limiter := getRateLimiter(userID.(string)) + // Convert UUID to string for rate limiting + var userIDStr string + switch v := userID.(type) { + case uuid.UUID: + userIDStr = v.String() + case string: + userIDStr = v + default: + log.Printf("Unexpected userID type: %T", userID) + common.ErrorResponses(c, http.StatusInternalServerError, "Invalid user ID type") + c.Abort() + return + } + + log.Printf("User ID for rate limiting: %s", userIDStr) + + limiter := getRateLimiter(userIDStr) if !limiter.Allow() { common.ErrorResponses(c, http.StatusTooManyRequests, "Too many requests") @@ -62,24 +81,23 @@ func RateLimitMiddleware() gin.HandlerFunc{ return } - c.Next() - } + c.Next() + } } func RateLoginMiddleware() gin.HandlerFunc{ - return func(c *gin.Context) { - if c.Request.Method == http.MethodOptions { - c.Next() - return - } - limiter := getLoginLimiter() - - if !limiter.Allow() { - common.ErrorResponses(c, http.StatusTooManyRequests, "Too many requests") - c.Abort() - return - } - c.Next() - } -} + return func(c *gin.Context) { + if c.Request.Method == http.MethodOptions { + c.Next() + return + } + limiter := getLoginLimiter() + if !limiter.Allow() { + common.ErrorResponses(c, http.StatusTooManyRequests, "Too many requests") + c.Abort() + return + } + c.Next() + } +} \ No newline at end of file diff --git a/middleware/rbac.go b/middleware/rbac.go new file mode 100644 index 0000000..0d03651 --- /dev/null +++ b/middleware/rbac.go @@ -0,0 +1,189 @@ +package middleware + +import ( + "net/http" + "users_management/m/config" + "users_management/m/utils/common" + + "github.com/gin-gonic/gin" +) + +// ConditionalRequireRole middleware that respects auth config +func ConditionalRequireRole(cfg *config.Config, allowedRoles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + // If auth is disabled, allow all requests + if !cfg.AuthConfig.UserAuthEnabled { + c.Next() + return + } + + // If auth is enabled, check roles normally + RequireRole(allowedRoles...)(c) + } +} + +// ConditionalRequireAnyRole middleware that respects auth config +func ConditionalRequireAnyRole(cfg *config.Config, roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + // If auth is disabled, allow all requests + if !cfg.AuthConfig.UserAuthEnabled { + c.Next() + return + } + + // If auth is enabled, check roles normally + RequireAnyRole(roles...)(c) + } +} +// RequireRole middleware to check if user has required role +func RequireRole(allowedRoles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + userRole, exists := c.Get("userRole") + if !exists { + common.ErrorResponses(c, http.StatusUnauthorized, "User role not found") + c.Abort() + return + } + + role, ok := userRole.(string) + if !ok { + common.ErrorResponses(c, http.StatusUnauthorized, "Invalid user role") + c.Abort() + return + } + + // Check if user role is in allowed roles + for _, allowedRole := range allowedRoles { + if role == allowedRole { + c.Next() + return + } + } + + common.ErrorResponses(c, http.StatusForbidden, "Insufficient permissions") + c.Abort() + } +} + +// RequireAnyRole middleware - user needs at least one of the specified roles +func RequireAnyRole(roles ...string) gin.HandlerFunc { + return RequireRole(roles...) +} + +// RequireAdminRole middleware for admin-only access +func RequireAdminRole() gin.HandlerFunc { + return RequireRole("Admin", "Super Admin") +} + +// RequireSuperAdminRole middleware for superadmin-only access +func RequireSuperAdminRole() gin.HandlerFunc { + return RequireRole("Super Admin") +} + +// RequireTeknisiRole middleware for teknisi access +func RequireTeknisiRole() gin.HandlerFunc { + return RequireRole("Teknisi") +} + +// RequireNonTeknisiRole middleware to block teknisi users +func RequireNonTeknisiRole() gin.HandlerFunc { + return func(c *gin.Context) { + userRole, exists := c.Get("userRole") + if !exists { + common.ErrorResponses(c, http.StatusUnauthorized, "User role not found") + c.Abort() + return + } + + role, ok := userRole.(string) + if !ok { + common.ErrorResponses(c, http.StatusUnauthorized, "Invalid user role") + c.Abort() + return + } + + if role == "Teknisi" { + common.ErrorResponses(c, http.StatusForbidden, "Teknisi users are not allowed to access this resource") + c.Abort() + return + } + + c.Next() + } +} + +// PermissionMiddleware for more granular permissions +func PermissionMiddleware(resource, action string) gin.HandlerFunc { + return func(c *gin.Context) { + userRole, exists := c.Get("userRole") + if !exists { + common.ErrorResponses(c, http.StatusUnauthorized, "User role not found") + c.Abort() + return + } + + role, ok := userRole.(string) + if !ok { + common.ErrorResponses(c, http.StatusUnauthorized, "Invalid user role") + c.Abort() + return + } + + if !hasPermission(role, resource, action) { + common.ErrorResponses(c, http.StatusForbidden, "Insufficient permissions for this action") + c.Abort() + return + } + + c.Next() + } +} + +// Permission checker function +func hasPermission(role, resource, action string) bool { + permissions := getPermissions() + + rolePermissions, exists := permissions[role] + if !exists { + return false + } + + resourcePermissions, exists := rolePermissions[resource] + if !exists { + return false + } + + for _, allowedAction := range resourcePermissions { + if allowedAction == action || allowedAction == "*" { + return true + } + } + + return false +} + +// Define permissions for each role +func getPermissions() map[string]map[string][]string { + return map[string]map[string][]string{ + "Superadmin": { + "*": {"*"}, // Full access to everything + }, + "Admin": { + "backbone": {"CREATE", "READ", "UPDATE", "DELETE"}, + "fishbone": {"CREATE", "READ", "UPDATE", "DELETE"}, + "devices": {"CREATE", "READ", "UPDATE", "DELETE"}, + "towers": {"CREATE", "READ", "UPDATE", "DELETE"}, + "ports": {"CREATE", "READ", "UPDATE", "DELETE"}, + "logs": {"READ"}, + "users": {"READ"}, + }, + "Teknisi": { + "backbone": {"CREATE", "READ", "UPDATE"}, + "fishbone": {"CREATE", "READ", "UPDATE"}, + "devices": {"CREATE", "READ", "UPDATE"}, + "towers": {"CREATE", "READ", "UPDATE"}, + "ports": {"CREATE", "READ", "UPDATE"}, + "logs": {"READ"}, // Only own logs + }, + } +} \ No newline at end of file diff --git a/model/dto/req/backbone_dto.go b/model/dto/req/backbone_dto.go index c293f41..e88d3f0 100644 --- a/model/dto/req/backbone_dto.go +++ b/model/dto/req/backbone_dto.go @@ -3,6 +3,7 @@ package req import "github.com/google/uuid" type BackboneDTO struct { + BackboneCode string `json:"backbone_code" validate:"required,min=3"` DeviceStartID uuid.UUID `json:"dev_start_id"` DeviceEndID uuid.UUID `json:"dev_end_id"` CoreAmount int `json:"core_amount"` diff --git a/model/dto/req/deviceDetails.go b/model/dto/req/deviceDetails.go new file mode 100644 index 0000000..a941e3a --- /dev/null +++ b/model/dto/req/deviceDetails.go @@ -0,0 +1,63 @@ +package req + +import ( + "users_management/m/model/entity" + + "github.com/google/uuid" +) + +type DeviceDetailsDTO struct { + DeviceCode string `json:"device_code" validate:"required"` + DeviceType string `json:"device_type" validate:"required,oneof=OTB ODP"` + Longitude float64 `json:"longitude" validate:"required"` + Latitude float64 `json:"latitude" validate:"required"` + PortAmount int `json:"port_amount" validate:"required,min=1,max=100"` + Status string `json:"status" validate:"required,oneof=active inactive maintenance"` + Region *string `json:"region,omitempty" validate:"omitempty,min=3"` + 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"` + TowerID *uuid.UUID `json:"tower_id,omitempty"` + OLTID *uuid.UUID `json:"olt_id,omitempty"` +} + +type UpdateDeviceDetailsDTO struct { + DeviceCode *string `json:"device_code,omitempty" validate:"omitempty,min=3"` + DeviceType *string `json:"device_type,omitempty" validate:"omitempty,oneof=OTB ODP CLOSURE"` + Longitude *float64 `json:"longitude,omitempty" validate:"omitempty,longitude"` + Latitude *float64 `json:"latitude,omitempty" validate:"omitempty,latitude"` + PortAmount *int `json:"port_amount,omitempty" validate:"omitempty,min=0,max=100"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=active inactive maintenance"` + Region *string `json:"region,omitempty" validate:"omitempty,min=3"` + 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"` + TowerID *uuid.UUID `json:"tower_id,omitempty"` + OLTID *uuid.UUID `json:"olt_id,omitempty"` +} + +type AssignMultipleCustomersDTO struct { + CustomerName string `json:"customer_name" binding:"required"` + PortNumber *int `json:"port_number,omitempty"` + Status *entity.PortStatus `json:"status,omitempty"` // Add status field + Bandwidth *string `json:"bandwidth,omitempty"` // Add bandwidth field +} + +type UpdateCustomerByPortDTO struct { + PortNumber int `json:"port_number,omitempty"` + NewCustomerName *string `json:"new_customer_name,omitempty"` + Status *entity.PortStatus `json:"status,omitempty"` // Add status field + Bandwidth *string `json:"bandwidth,omitempty"` // Add bandwidth field +} + +type BulkUpdateCustomersByPortDTO struct { + Updates []UpdateCustomerByPortDTO `json:"updates" binding:"required"` +} + +type RemoveCustomerByPortDTO struct { + PortNumber int `json:"port_number" binding:"required,min=1"` +} + +type DeleteImageDTO struct { + Filename string `json:"filename" validate:"required"` +} \ No newline at end of file diff --git a/model/dto/req/devicePort_dto.go b/model/dto/req/devicePort_dto.go index b4c0ec5..edf0ada 100644 --- a/model/dto/req/devicePort_dto.go +++ b/model/dto/req/devicePort_dto.go @@ -1,6 +1,10 @@ package req -import "github.com/google/uuid" +import ( + "users_management/m/model/entity" + + "github.com/google/uuid" +) type DevicePort struct { DeviceID uuid.UUID `json:"device_id"` @@ -10,4 +14,27 @@ type DevicePort struct { type UpdateDevicePort struct { DeviceID *uuid.UUID `json:"device_id,omitempty" validate:"omitempty,min=3"` PortNumber *int `json:"port_number,omitempty" validate:"omitempty,min=1"` +} + + +type AssignCustomerToPortDTO struct { + CustomerName string `json:"customer_name" binding:"required"` + PortNumber *int `json:"port_number,omitempty"` // Optional, will auto-assign if not provided + Status *entity.PortStatus `json:"status,omitempty"` // Add status field + Bandwidth *string `json:"bandwidth,omitempty"` // Add bandwidth field +} + +type UpdatePortUsageDTO struct { + PortUsed int `json:"port_used" binding:"required,min=0"` +} + +type PortAssignmentDTO struct { + PortNumber int `json:"port_number" binding:"required,min=1"` + CustomerName *string `json:"customer_name,omitempty"` + Status *entity.PortStatus `json:"status,omitempty" validate:"omitempty,portstatus"` + Bandwidth *string `json:"bandwidth,omitempty" validate:"omitempty,min=1"` +} + +type UpdatePortAssignmentsDTO struct { + PortAssignments []PortAssignmentDTO `json:"port_assignments" binding:"required"` } \ No newline at end of file diff --git a/model/dto/req/device_dto.go b/model/dto/req/device_dto.go index 1259c07..4df8c56 100644 --- a/model/dto/req/device_dto.go +++ b/model/dto/req/device_dto.go @@ -1,13 +1,19 @@ 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"` + Longitude float64 `json:"longitude" validate:"required"` Latitude float64 `json:"latitude" validate:"required"` - PortAmount int `json:"port_amount" 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"` + TowerID *uuid.UUID `json:"tower_id,omitempty"` + OLTID *uuid.UUID `json:"olt_id,omitempty"` } type UpdateDeviceDTO struct { @@ -15,6 +21,19 @@ type UpdateDeviceDTO struct { 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,min=1"` + 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"` + TowerID *uuid.UUID `json:"tower_id,omitempty"` + OLTID *uuid.UUID `json:"olt_id,omitempty"` +} + +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/device_inspections_dto.go b/model/dto/req/device_inspections_dto.go new file mode 100644 index 0000000..8e5de21 --- /dev/null +++ b/model/dto/req/device_inspections_dto.go @@ -0,0 +1,38 @@ +package req + +import "github.com/google/uuid" + +type DeviceInspectionDTO struct { + DeviceID uuid.UUID `json:"device_id" validate:"required"` + BackboneID *uuid.UUID `json:"backbone_id,omitempty"` + FishboneID *uuid.UUID `json:"fishbone_id,omitempty"` + TowerID *uuid.UUID `json:"tower_id,omitempty"` + Status string `json:"status" validate:"required,oneof=pending in_progress completed maintenance"` + PortUsed string `json:"port_used" validate:"required"` + PortAvailable string `json:"port_available"` + Description string `json:"description"` + ImageURL *string `json:"image_url,omitempty"` + InspectionPlacement struct { + Longitude float64 `json:"longitude" validate:"required"` + Latitude float64 `json:"latitude" validate:"required"` + } `json:"inspection_placement" validate:"required"` +} + +type UpdateDeviceInspectionDTO struct { + BackboneID *uuid.UUID `json:"backbone_id,omitempty"` + FishboneID *uuid.UUID `json:"fishbone_id,omitempty"` + TowerID *uuid.UUID `json:"tower_id,omitempty"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=pending in_progress completed maintenance"` + PortUsed *string `json:"port_used,omitempty"` + Description *string `json:"description,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + InspectionPlacement *struct { + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + } `json:"inspection_placement,omitempty"` +} + +type ApproveInspectionDTO struct { + InspectionApproval string `json:"inspection_approval" validate:"required,oneof=approved rejected"` + Notes string `json:"notes,omitempty"` +} \ No newline at end of file diff --git a/model/dto/req/fishbone_dto.go b/model/dto/req/fishbone_dto.go index 0c64f75..1a113dc 100644 --- a/model/dto/req/fishbone_dto.go +++ b/model/dto/req/fishbone_dto.go @@ -3,15 +3,17 @@ package req import "github.com/google/uuid" type FishboneDTO struct { - BackboneID uuid.UUID `json:"bb_id"` - DeviceStartID uuid.UUID `json:"dev_start_id"` - DeviceEndID uuid.UUID `json:"dev_end_id"` - CoreAmount int `json:"core_amount"` + FishboneCode string `json:"fishbone_code" validate:"required,min=3"` + BackboneID uuid.UUID `json:"bb_id" validate:"required"` + DeviceStartID uuid.UUID `json:"dev_start_id" validate:"required"` + DeviceEndID uuid.UUID `json:"dev_end_id" validate:"required"` + CoreAmount int `json:"core_amount" validate:"required,min=1"` } type UpdateFishboneDTO struct { - BackboneID *uuid.UUID `json:"bb_id,omitempty" validate:"omitempty,min=3"` - DeviceStartID *uuid.UUID `json:"dev_start_id,omitempty" validate:"omitempty,min=3"` - DeviceEndID *uuid.UUID `json:"dev_end_id,omitempty" validate:"omitempty,min=3"` - CoreAmount *int `json:"core_amount,omitempty" validate:"omitempty,min=1"` + FishboneCode *string `json:"fishbone_code,omitempty" validate:"omitempty,min=3"` + BackboneID *uuid.UUID `json:"bb_id,omitempty"` + DeviceStartID *uuid.UUID `json:"dev_start_id,omitempty"` + DeviceEndID *uuid.UUID `json:"dev_end_id,omitempty"` + CoreAmount *int `json:"core_amount,omitempty" validate:"omitempty,min=1"` } \ No newline at end of file diff --git a/model/dto/req/nearest_devices.go b/model/dto/req/nearest_devices.go new file mode 100644 index 0000000..1313e82 --- /dev/null +++ b/model/dto/req/nearest_devices.go @@ -0,0 +1,11 @@ +package req + +type NearestDeviceDTO struct { + Longitude float64 `json:"longitude" validate:"required"` + Latitude float64 `json:"latitude" validate:"required"` + Radius float64 `json:"radius" validate:"omitempty,min=0.1,max=50"` // Default 5km, max 50km + Limit int `json:"limit" validate:"omitempty,min=1,max=100"` // Default 10, max 100 + Province *string `json:"province,omitempty"` + City *string `json:"city,omitempty"` + District *string `json:"district,omitempty"` +} \ No newline at end of file diff --git a/model/dto/req/nearest_towers.go b/model/dto/req/nearest_towers.go new file mode 100644 index 0000000..d64bbfe --- /dev/null +++ b/model/dto/req/nearest_towers.go @@ -0,0 +1,11 @@ +package req + +type NearestTowerDTO struct { + Longitude float64 `json:"longitude" validate:"required"` + Latitude float64 `json:"latitude" validate:"required"` + Radius float64 `json:"radius" validate:"omitempty,min=0.1,max=50"` // Default 5km, max 50km + Limit int `json:"limit" validate:"omitempty,min=1,max=100"` // Default 10, max 100 + Province *string `json:"province,omitempty"` + City *string `json:"city,omitempty"` + District *string `json:"district,omitempty"` +} \ No newline at end of file diff --git a/model/dto/req/olt.go b/model/dto/req/olt.go new file mode 100644 index 0000000..6202a83 --- /dev/null +++ b/model/dto/req/olt.go @@ -0,0 +1,19 @@ +package req + +import "github.com/google/uuid" + +type OLTDTO struct { + OLTName string `json:"olt_name" validate:"required,min=3,max=100"` +} + +type UpdateOLTDTO struct { + OLTName *string `json:"olt_name,omitempty" validate:"omitempty,min=3,max=100"` +} + +type AssignDeviceToOLTDTO struct { + DeviceID uuid.UUID `json:"device_id" validate:"required"` +} + +type UnassignDeviceFromOLTDTO struct { + DeviceID uuid.UUID `json:"device_id" validate:"required"` +} \ No newline at end of file diff --git a/model/dto/req/tower_dto.go b/model/dto/req/tower_dto.go index a91ddc2..fd05c66 100644 --- a/model/dto/req/tower_dto.go +++ b/model/dto/req/tower_dto.go @@ -3,15 +3,29 @@ package req import "github.com/google/uuid" type TowerDTO struct { - DeviceID uuid.UUID `json:"dev_id"` - TowerCode string `json:"tower_code"` - Longitude float64 `json:"longitude"` - Latitude float64 `json:"latitude"` + DeviceID *uuid.UUID `json:"dev_id,omitempty"` + DeviceName *string `json:"device_name,omitempty"` + TowerCode string `json:"tower_code" validate:"required"` + Longitude float64 `json:"longitude" validate:"required"` + Latitude float64 `json:"latitude" validate:"required"` + ExternalTower *bool `json:"external_tower,omitempty"` // Make nullable } type UpdateTowerDTO struct { - DeviceID *string `json:"device_id,omitempty" validate:"omitempty,min=3"` - TowerCode *string `json:"tower_code,omitempty" validate:"omitempty"` - Longitude *float64 `json:"longitude,omitempty" validate:"omitempty,longitude"` - Latitude *float64 `json:"latitude,omitempty" validate:"omitempty,latitude"` + DeviceID *uuid.UUID `json:"device_id,omitempty"` + DeviceName *string `json:"device_name,omitempty"` + TowerCode *string `json:"tower_code,omitempty"` + Longitude *float64 `json:"longitude,omitempty" validate:"omitempty,longitude"` + 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/activity_logs_res.go b/model/dto/res/activity_logs_res.go new file mode 100644 index 0000000..08f3993 --- /dev/null +++ b/model/dto/res/activity_logs_res.go @@ -0,0 +1,34 @@ +package res + +import ( + "time" + "github.com/google/uuid" +) + +// ActivityLogResponse - simplified response for users +type ActivityLogResponse struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + Action string `json:"action"` + Resource string `json:"resource"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + Timestamp time.Time `json:"timestamp"` +} + +// ActivityLogDetailResponse - detailed response for admins (if needed) +type ActivityLogDetailResponse struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + Action string `json:"action"` + Resource string `json:"resource"` + ResourceID *string `json:"resource_id,omitempty"` + OldData *string `json:"old_data,omitempty"` + NewData *string `json:"new_data,omitempty"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + Timestamp time.Time `json:"timestamp"` +} \ No newline at end of file diff --git a/model/dto/res/backbone_res.go b/model/dto/res/backbone_res.go new file mode 100644 index 0000000..81798cc --- /dev/null +++ b/model/dto/res/backbone_res.go @@ -0,0 +1,20 @@ +package res + +import ( + "time" + + "github.com/google/uuid" +) + +type BackboneResponse struct { + ID uuid.UUID `json:"id"` + BackboneCode string `json:"backbone_code"` + DevStart string `json:"dev_start"` + DevEnd string `json:"dev_end"` + DeviceStartID uuid.UUID `json:"device_start_id"` + DeviceEndID uuid.UUID `json:"device_end_id"` + CoreAmount int `json:"core_amount"` + TotalFishbone int `json:"total_fishbone"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} \ No newline at end of file diff --git a/model/dto/res/deviceType_res.go b/model/dto/res/deviceType_res.go new file mode 100644 index 0000000..9ef5907 --- /dev/null +++ b/model/dto/res/deviceType_res.go @@ -0,0 +1,9 @@ +package res + +import "github.com/google/uuid" + +type DeviceTypeResponse struct { + ID uuid.UUID `json:"id"` + DeviceType string `json:"device_type"` + DeviceCode string `json:"device_code"` +} \ No newline at end of file diff --git a/model/dto/res/device_details.go b/model/dto/res/device_details.go new file mode 100644 index 0000000..f6a3fd7 --- /dev/null +++ b/model/dto/res/device_details.go @@ -0,0 +1,61 @@ +package res + +import ( + "time" + "users_management/m/model/entity" + + "github.com/google/uuid" +) + +type DeviceDetailsResponse struct { + ID uuid.UUID `json:"id"` + DeviceCode string `json:"device_code"` + DeviceType string `json:"device_type"` + Address string `json:"address"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + Status string `json:"status"` + PortAmount int `json:"port_amount"` + PortUsed int `json:"port_used"` + PortAvailable int `json:"port_available"` + CustomerNames []string `json:"customer_names"` // Just customer names + PortAssignments []PortAssignmentResponse `json:"port_assignments"` // Port details with customers + Region *string `json:"region,omitempty"` + Province *string `json:"province,omitempty"` + City *string `json:"city,omitempty"` + District *string `json:"district,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + ImageURLs []string `json:"image_urls"` + TowerID *uuid.UUID `json:"tower_id,omitempty"` // Add TowerID + OLT *OLTInfo `json:"olt"` // Add OLT name + + // Connection details + Backbones []BackboneConnectionInfo `json:"backbones"` + Fishbones []FishboneConnectionInfo `json:"fishbones"` + Towers []TowerConnectionDetail `json:"towers"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OLTInfo struct { + OLTID uuid.UUID `json:"id"` + OLTName string `json:"name"` +} + +type TowerConnectionDetail struct { + ID uuid.UUID `json:"id"` + TowerCode string `json:"tower_code"` + 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 { + PortNumber int `json:"port_number"` + CustomerName *string `json:"customer_name"` + IsOccupied bool `json:"is_occupied"` + Status entity.PortStatus `json:"status"` // Add status field + Bandwidth *string `json:"bandwidth"` // Add bandwidth field +} \ No newline at end of file diff --git a/model/dto/res/device_inspections_res.go b/model/dto/res/device_inspections_res.go new file mode 100644 index 0000000..c7aced3 --- /dev/null +++ b/model/dto/res/device_inspections_res.go @@ -0,0 +1,72 @@ +package res + +import ( + "time" + "github.com/google/uuid" +) + +type DeviceInspectionResponse struct { + ID uuid.UUID `json:"id"` + DeviceCode string `json:"device_code"` + DeviceType string `json:"device_type"` + UserName string `json:"user_name"` + BackboneCode *string `json:"backbone_code,omitempty"` + FishboneCode *string `json:"fishbone_code,omitempty"` + TowerCode *string `json:"tower_code,omitempty"` + Status string `json:"status"` + PortUsed string `json:"port_used"` + PortAvailable string `json:"port_available"` + CableAmount int `json:"cable_amount"` + Description string `json:"description"` + ImageURL *string `json:"image_url,omitempty"` + InspectionPlacement string `json:"inspection_placement"` // Full address + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + InspectionApproval string `json:"inspection_approval"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DeviceInspectionDetailResponse struct { + ID uuid.UUID `json:"id"` + Device DeviceInfo `json:"device"` + User UserInfo `json:"user"` + Backbone *BackboneInfo `json:"backbone,omitempty"` + Fishbone *FishboneInfo `json:"fishbone,omitempty"` + Tower *TowerInfo `json:"tower,omitempty"` + Status string `json:"status"` + PortUsed string `json:"port_used"` + PortAvailable string `json:"port_available"` + CableAmount int `json:"cable_amount"` + Description string `json:"description"` + ImageURL *string `json:"image_url,omitempty"` + InspectionPlacement string `json:"inspection_placement"` // Full address + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + InspectionApproval string `json:"inspection_approval"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + + +type DeviceInfo struct { + ID uuid.UUID `json:"id"` + DeviceCode string `json:"device_code"` + DeviceType string `json:"device_type"` +} + +type UserInfo struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Username string `json:"username"` +} + +type TowerInfo struct { + ID uuid.UUID `json:"id"` + TowerCode string `json:"tower_code"` +} + +type FishboneInfo struct { + ID uuid.UUID `json:"id"` + FishboneCode string `json:"fishbone_code"` +} \ No newline at end of file diff --git a/model/dto/res/device_res.go b/model/dto/res/device_res.go new file mode 100644 index 0000000..0361989 --- /dev/null +++ b/model/dto/res/device_res.go @@ -0,0 +1,26 @@ +package res + +import ( + "time" + + "github.com/google/uuid" +) + +type DeviceResponse struct { + ID uuid.UUID `json:"id"` + DeviceCode string `json:"device_code"` + DeviceType string `json:"device_type"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + PortAmount int `json:"port_amount"` + 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/fishbone_res.go b/model/dto/res/fishbone_res.go new file mode 100644 index 0000000..bd29388 --- /dev/null +++ b/model/dto/res/fishbone_res.go @@ -0,0 +1,36 @@ +package res + +import ( + "time" + + "github.com/google/uuid" +) + +type FishboneResponse struct { + ID uuid.UUID `json:"id"` + FishboneCode string `json:"fishbone_code"` + BackboneCode string `json:"backbone_code"` + DeviceStart string `json:"device_start"` + DeviceEnd string `json:"device_end"` + DeviceStartID uuid.UUID `json:"device_start_id"` + DeviceEndID uuid.UUID `json:"device_end_id"` + CoreAmount int `json:"core_amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type FishboneDetailResponse struct { + ID uuid.UUID `json:"id"` + FishboneCode string `json:"fishbone_code"` + DeviceStartID uuid.UUID `json:"device_start_id"` + DeviceEndID uuid.UUID `json:"device_end_id"` + BackboneCode string `json:"backbone_code"` + CoreAmount int `json:"core_amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type BackboneInfo struct { + ID uuid.UUID `json:"id"` + BackboneCode string `json:"backbone_code"` +} diff --git a/model/dto/res/nearest_devices.go b/model/dto/res/nearest_devices.go new file mode 100644 index 0000000..0ef8b4f --- /dev/null +++ b/model/dto/res/nearest_devices.go @@ -0,0 +1,82 @@ +package res + +import ( + "time" + "github.com/google/uuid" +) + +// Simplified response for list +type NearestDeviceResponse struct { + ID uuid.UUID `json:"id"` + DeviceCode string `json:"device_code"` + DeviceType string `json:"device_type"` + Distance float64 `json:"distance_km"` + Address string `json:"address"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + Status string `json:"status"` + PortAmount int `json:"port_amount"` + Region *string `json:"region,omitempty"` + Province *string `json:"province,omitempty"` + City *string `json:"city,omitempty"` + District *string `json:"district,omitempty"` + ImageURL *string `json:"image_url"` + + // Connection counts + BackboneCount int `json:"backbone_count"` + FishboneCount int `json:"fishbone_count"` + TowerCount int `json:"tower_count"` + + CreatedAt time.Time `json:"created_at"` +} + +// Detailed response for single device +type NearestDeviceDetailResponse struct { + ID uuid.UUID `json:"id"` + DeviceCode string `json:"device_code"` + DeviceType string `json:"device_type"` + Distance float64 `json:"distance_km"` + Address string `json:"address"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + Status string `json:"status"` + PortAmount int `json:"port_amount"` + Region *string `json:"region,omitempty"` + Province *string `json:"province,omitempty"` + City *string `json:"city,omitempty"` + District *string `json:"district,omitempty"` + ImageURL *string `json:"image_url"` + ImageURLs []string `json:"image_urls"` + + // Detailed connection information + Backbones []BackboneConnectionInfo `json:"backbones"` + Fishbones []FishboneConnectionInfo `json:"fishbones"` + Towers []TowerConnectionInfo `json:"towers"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type BackboneConnectionInfo struct { + ID uuid.UUID `json:"id"` + BackboneCode string `json:"backbone_code"` + CoreAmount int `json:"core_amount"` + IsStartDevice bool `json:"is_start_device"` + ConnectedTo string `json:"connected_to"` // Device code of the other end +} + +type FishboneConnectionInfo struct { + ID uuid.UUID `json:"id"` + FishboneCode string `json:"fishbone_code"` + CoreAmount int `json:"core_amount"` + BackboneCode string `json:"backbone_code"` + IsStartDevice bool `json:"is_start_device"` + ConnectedTo string `json:"connected_to"` // Device code of the other end +} + +type TowerConnectionInfo struct { + ID uuid.UUID `json:"id"` + TowerCode string `json:"tower_code"` + Distance float64 `json:"distance_km"` // Distance from tower to device + ImageURL *string `json:"image_url,omitempty"` +} \ No newline at end of file diff --git a/model/dto/res/nearest_towers.go b/model/dto/res/nearest_towers.go new file mode 100644 index 0000000..701fd74 --- /dev/null +++ b/model/dto/res/nearest_towers.go @@ -0,0 +1,54 @@ +package res + +import ( + "time" + "github.com/google/uuid" +) + +// Simplified response for list +type NearestTowerResponse struct { + ID uuid.UUID `json:"id"` + TowerCode string `json:"tower_code"` + Distance float64 `json:"distance_km"` + Address string `json:"address"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + ImageURL *string `json:"image_url,omitempty"` + ExternalTower *bool `json:"external_tower,omitempty"` + DeviceCode *string `json:"device_code,omitempty"` + Province *string `json:"province,omitempty"` + City *string `json:"city,omitempty"` + District *string `json:"district,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Detailed response for single tower +type NearestTowerDetailResponse struct { + ID uuid.UUID `json:"id"` + TowerCode string `json:"tower_code"` + Distance float64 `json:"distance_km"` + Address string `json:"address"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + ImageURL *string `json:"image_url,omitempty"` + ImageURLs []string `json:"image_urls,omitempty"` + ExternalTower *bool `json:"external_tower,omitempty"` + + // Connected device information + Device *DeviceConnectionInfo `json:"device,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DeviceConnectionInfo struct { + ID uuid.UUID `json:"id"` + DeviceCode string `json:"device_code"` + DeviceType string `json:"device_type"` + Distance float64 `json:"distance_from_towers_to_device_km"` // Distance from tower to device + Address string `json:"address"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + Status string `json:"status"` + PortAmount int `json:"port_amount"` +} \ No newline at end of file diff --git a/model/dto/res/olt.go b/model/dto/res/olt.go new file mode 100644 index 0000000..bedbc9a --- /dev/null +++ b/model/dto/res/olt.go @@ -0,0 +1,39 @@ +package res + +import ( + "time" + "github.com/google/uuid" +) + +type OLTResponse struct { + ID uuid.UUID `json:"id"` + OLTName string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OLTDetailResponse struct { + ID uuid.UUID `json:"id"` + OLTName string `json:"name"` + DeviceCount int `json:"device_count"` + Devices []DeviceDetailsResponse `json:"devices"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OLTDeviceResponse struct { + ID uuid.UUID `json:"id"` + DeviceCode string `json:"device_code"` + DeviceType string `json:"device_type"` + Address string `json:"address"` + Province *string `json:"province,omitempty"` + City *string `json:"city,omitempty"` + District *string `json:"district,omitempty"` + Status string `json:"status"` + PortAmount int `json:"port_amount"` + PortUsed int `json:"port_used"` + ImageURL *string `json:"image_url,omitempty"` + 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 new file mode 100644 index 0000000..890d28a --- /dev/null +++ b/model/dto/res/tower_res.go @@ -0,0 +1,20 @@ +package res + +import ( + "time" + "github.com/google/uuid" +) + +type TowerResponse struct { + ID uuid.UUID `json:"id"` + DeviceCode *string `json:"device_code,omitempty"` // Make nullable + TowerCode *string `json:"tower_code"` + Longitude float64 `json:"longitude"` + 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"` +} \ No newline at end of file diff --git a/model/dto/user_dto.go b/model/dto/user_dto.go index 1bd6858..2a572e4 100644 --- a/model/dto/user_dto.go +++ b/model/dto/user_dto.go @@ -1,13 +1,39 @@ package dto +import ( + "time" + + "github.com/google/uuid" +) + +// UserStatus defines the possible status values for a user +type UserStatus string + +const ( + UserStatusApproved UserStatus = "approved" + UserStatusRejected UserStatus = "rejected" + UserStatusPending UserStatus = "pending" +) + type UserRegisterDTO struct { - Name string `json:"name" validate:"required"` - Username string `json:"username" validate:"required"` - Password string `json:"password" validate:"required"` - RoleID string `json:"role_id" validate:"required,uuid"` + Name string `json:"name" validate:"required,min=2,max=100,alphaspace"` + Username string `json:"username" validate:"required,min=3,max=50,alphanumunderscore,nospace"` + Password string `json:"password" validate:"required,min=6,max=100"` + NomorInduk string `json:"nomor_induk" validate:"required,min=3,max=50,alphanum"` } type UserLoginDTO struct { - Username string `json:"username" validate:"required"` - Password string `json:"password" validate:"required"` + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` + +} +type PendingUserResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Username string `json:"username"` + NomorInduk *string `json:"nomor_induk"` + RoleName string `json:"role_name"` + Status UserStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } \ No newline at end of file diff --git a/model/entity/activity_logs.go b/model/entity/activity_logs.go new file mode 100644 index 0000000..e133898 --- /dev/null +++ b/model/entity/activity_logs.go @@ -0,0 +1,25 @@ +package entity + +import ( + "time" + "github.com/google/uuid" +) + +type ActivityLog struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null"` + User User `json:"user" gorm:"foreignKey:UserID"` + Action string `json:"action" gorm:"not null"` // CREATE, UPDATE, DELETE, LOGIN, LOGOUT + Resource string `json:"resource" gorm:"not null"` // devices, backbone, fishbone, towers, etc. + ResourceID *string `json:"resource_id,omitempty"` // ID of the affected resource + OldData *string `json:"old_data,omitempty" gorm:"type:text"` // JSON of old data for updates + NewData *string `json:"new_data,omitempty" gorm:"type:text"` // JSON of new data + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + Timestamp time.Time `json:"timestamp" gorm:"autoCreateTime"` + Details *string `json:"details,omitempty"` // Additional details +} + +func (ActivityLog) TableName() string { + return "activity_logs" +} \ No newline at end of file diff --git a/model/entity/backbone.go b/model/entity/backbone.go index 9bfc603..d042a33 100644 --- a/model/entity/backbone.go +++ b/model/entity/backbone.go @@ -1,21 +1,21 @@ package entity import ( - "time" - - "github.com/google/uuid" + "time" + "github.com/google/uuid" ) type Backbone struct { - ID uuid.UUID `json:"id"` - DeviceStartID uuid.UUID `json:"dev_start_id" gorm:"column:dev_start_id"` - DeviceEndID uuid.UUID `json:"dev_end_id" gorm:"column:dev_end_id"` - CoreAmount int `json:"core_amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + BackboneCode string `json:"backbone_code" gorm:"unique"` + DeviceStartID uuid.UUID `json:"dev_start_id" gorm:"type:uuid;column:dev_start_id"` + DeviceEndID uuid.UUID `json:"dev_end_id" gorm:"type:uuid;column:dev_end_id"` + CoreAmount int `json:"core_amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` - DeviceStart Device `gorm:"foreignKey:DeviceStartID"` - DeviceEnd Device `gorm:"foreignKey:DeviceEndID"` + DeviceStart Device `gorm:"foreignKey:DeviceStartID"` + DeviceEnd Device `gorm:"foreignKey:DeviceEndID"` } func (Backbone) TableName() string { diff --git a/model/entity/count_assets.go b/model/entity/count_assets.go new file mode 100644 index 0000000..0b0e06f --- /dev/null +++ b/model/entity/count_assets.go @@ -0,0 +1,20 @@ +package entity + +import ( + "time" + "github.com/google/uuid" +) + +type CountAssets struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + FishboneAmount int `json:"fishbone_amount" gorm:"column:fishbone_amount"` + BackboneAmount int `json:"backbone_amount" gorm:"column:backbone_amount"` + DeviceAmount int `json:"device_amount" gorm:"column:device_amount"` + TowerAmount int `json:"tower_amount" gorm:"column:tower_amount"` + CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` + UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"` +} + +func (CountAssets) TableName() string { + return "count_assets" +} \ No newline at end of file diff --git a/model/entity/device_details.go b/model/entity/device_details.go new file mode 100644 index 0000000..8c51912 --- /dev/null +++ b/model/entity/device_details.go @@ -0,0 +1,99 @@ +package entity + +import ( + "time" + "github.com/google/uuid" +) + +type DeviceDetails struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + DeviceCode string `json:"device_code" gorm:"unique"` + DeviceType DeviceType `json:"device_type"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + PortAmount int `json:"port_amount"` + Status DeviceStatus `json:"status"` + Region *string `json:"region,omitempty" gorm:"type:varchar(255)"` + Province *string `json:"province,omitempty" gorm:"type:varchar(255)"` + 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"` + OLTID *uuid.UUID `json:"olt_id,omitempty" gorm:"type:uuid"` + TowerID *uuid.UUID `json:"tower_id,omitempty" gorm:"type:uuid"` // Add TowerID field + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relationships + DevicePort DevicePort `json:"device_port" gorm:"foreignKey:DeviceID;references:ID"` + BackbonesStart []Backbone `json:"backbones_start" gorm:"foreignKey:DeviceStartID;references:ID"` + BackbonesEnd []Backbone `json:"backbones_end" gorm:"foreignKey:DeviceEndID;references:ID"` + FishbonesStart []Fishbone `json:"fishbones_start" gorm:"foreignKey:DeviceStartID;references:ID"` + FishbonesEnd []Fishbone `json:"fishbones_end" gorm:"foreignKey:DeviceEndID;references:ID"` + OLT *OLT `json:"olt,omitempty" gorm:"foreignKey:OLTID;references:ID"` + Tower *Tower `json:"tower,omitempty" gorm:"foreignKey:TowerID;references:ID"` // Add Tower relationship + Towers []Tower `json:"towers" gorm:"foreignKey:DeviceID;references:ID"` +} + + + +func (d *DeviceDetails) 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 != "" { + // Avoid duplicates + isDuplicate := false + for _, existingImg := range allImages { + if existingImg == img { + isDuplicate = true + break + } + } + if !isDuplicate { + allImages = append(allImages, img) + } + } + } + + return allImages +} + +func (d *DeviceDetails) SetMultipleImages(imageURLs []string) { + if len(imageURLs) == 0 { + return + } + + // Set primary image + primaryImage := imageURLs[0] + d.ImageURL = &primaryImage + + // Set all images + d.ImageURLs = StringSlice(imageURLs) +} + +// Helper method to get all backbones connected to this device +func (d *DeviceDetails) GetAllBackbones() []Backbone { + allBackbones := make([]Backbone, 0) + allBackbones = append(allBackbones, d.BackbonesStart...) + allBackbones = append(allBackbones, d.BackbonesEnd...) + return allBackbones +} + +// Helper method to get all fishbones connected to this device +func (d *DeviceDetails) GetAllFishbones() []Fishbone { + allFishbones := make([]Fishbone, 0) + allFishbones = append(allFishbones, d.FishbonesStart...) + allFishbones = append(allFishbones, d.FishbonesEnd...) + return allFishbones +} + +func (DeviceDetails) TableName() string { + return "devices" // Use same table as Device entity +} \ No newline at end of file diff --git a/model/entity/device_inspection.go b/model/entity/device_inspection.go new file mode 100644 index 0000000..2b83280 --- /dev/null +++ b/model/entity/device_inspection.go @@ -0,0 +1,38 @@ +package entity + +import ( + "time" + "github.com/google/uuid" +) + +type DeviceInspection struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + DeviceID uuid.UUID `json:"device_id" gorm:"type:uuid;column:device_id"` + BackboneID *uuid.UUID `json:"backbone_id,omitempty" gorm:"type:uuid;column:backbone_id"` + FishboneID *uuid.UUID `json:"fishbone_id,omitempty" gorm:"type:uuid;column:fishbone_id"` + TowerID *uuid.UUID `json:"tower_id,omitempty" gorm:"type:uuid;column:tower_id"` + UserID uuid.UUID `json:"user_id" gorm:"type:uuid;column:user_id"` + Status string `json:"status" gorm:"type:varchar(50);column:status"` + PortUsed string `json:"port_used" gorm:"type:varchar(50);column:port_used"` + PortAvailable string `json:"port_available" gorm:"type:varchar(50);column:port_available"` + CableAmount int `json:"cable_amount" gorm:"type:int;column:cable_amount"` + Description string `json:"description" gorm:"type:text;column:description"` + ImageURL *string `json:"image_url,omitempty" gorm:"type:text;column:image_url"` + InspectionPlacement string `json:"inspection_placement" gorm:"type:text;column:inspection_placement"` // Store the full address from OpenStreetMap + Longitude float64 `json:"longitude" gorm:"type:float;column:longitude"` + Latitude float64 `json:"latitude" gorm:"type:float;column:latitude"` + InspectionApproval string `json:"inspection_approval" gorm:"type:varchar(50);column:inspection_approval"` + CreatedAt time.Time `json:"created_at" gorm:"type:timestamp;column:created_at"` + UpdatedAt time.Time `json:"updated_at" gorm:"type:timestamp;column:updated_at"` + + // Relationships + Device Device `json:"device" gorm:"foreignKey:DeviceID"` + Backbone *Backbone `json:"backbone,omitempty" gorm:"foreignKey:BackboneID"` + Fishbone *Fishbone `json:"fishbone,omitempty" gorm:"foreignKey:FishboneID"` + User User `json:"user" gorm:"foreignKey:UserID"` + Tower *Tower `json:"tower,omitempty" gorm:"foreignKey:TowerID"` +} + +func (DeviceInspection) TableName() string { + return "device_inspection" +} \ No newline at end of file diff --git a/model/entity/device_port.go b/model/entity/device_port.go index 6e4e8a5..4aed908 100644 --- a/model/entity/device_port.go +++ b/model/entity/device_port.go @@ -1,21 +1,299 @@ package entity import ( + "database/sql/driver" + "encoding/json" + "fmt" + "sort" "time" "github.com/google/uuid" ) -type DevicePort struct { - ID uuid.UUID `json:"id"` - DeviceID uuid.UUID `json:"device_id" gorm:"column:device_id"` - PortNumber int `json:"port_number"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +type PortStatus string - Device Device `gorm:"foreignKey:DeviceID"` +const ( + PortStatusOn PortStatus = "on" + PortStatusOff PortStatus = "off" + PortStatusDyingGasp PortStatus = "dyingGasp" + PortStatusLOS PortStatus = "los" +) + +type StringSlice []string + +func (s StringSlice) Value() (driver.Value, error) { + if len(s) == 0 { + return "[]", nil + } + return json.Marshal(s) +} + +func (s *StringSlice) Scan(value interface{}) error { + if value == nil { + *s = StringSlice{} + return nil + } + + var bytes []byte + switch v := value.(type) { + case []byte: // []byte and []uint8 are the same type in Go + bytes = v + case string: + bytes = []byte(v) + default: + return fmt.Errorf("cannot scan %T into StringSlice", value) + } + + // Handle empty string case + if len(bytes) == 0 { + *s = StringSlice{} + return nil + } + + return json.Unmarshal(bytes, s) +} + +type PortAssignment struct { + PortNumber int `json:"port_number"` + CustomerName *string `json:"customer_name"` // Nullable for empty ports + Status PortStatus `json:"status"` // Add status field + Bandwidth *string `json:"bandwidth"` // Add bandwidth field (nullable) +} + +type PortAssignmentResponse struct { + PortNumber int `json:"port_number"` + CustomerName *string `json:"customer_name"` + IsOccupied bool `json:"is_occupied"` + Status PortStatus `json:"status"` // Add status field + Bandwidth *string `json:"bandwidth"` // Add bandwidth field +} + +type PortAssignments []PortAssignment + +func (p PortAssignments) Value() (driver.Value, error) { + if len(p) == 0 { + return "[]", nil + } + return json.Marshal(p) +} + +func (p *PortAssignments) Scan(value interface{}) error { + if value == nil { + *p = PortAssignments{} + return nil + } + + var bytes []byte + switch v := value.(type) { + 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 + } + + return json.Unmarshal(bytes, p) +} + +type DevicePort struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + DeviceID uuid.UUID `json:"device_id" gorm:"type:uuid;not null"` + PortUsed int `json:"port_used"` + PortAvailable int `json:"port_available"` + CustomerCount int `json:"customer_count"` + CustomerNames StringSlice `json:"customer_names" gorm:"type:jsonb"` + PortAssignments PortAssignments `json:"port_assignments" gorm:"type:jsonb"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relationships + Device Device `json:"device" gorm:"foreignKey:DeviceID"` +} + +// Helper method to get customer names with port numbers +func (dp *DevicePort) GetCustomerNamesWithPorts() []string { + if dp.Device.DeviceType == "OTB" { + return []string{} // OTB devices do not have port assignments + } + var result []string + for _, assignment := range dp.PortAssignments { + if assignment.CustomerName != nil && *assignment.CustomerName != "" { + result = append(result, fmt.Sprintf("%d: %s", assignment.PortNumber, *assignment.CustomerName)) + } + } + // If no port assignments, fall back to customer names + if len(result) == 0 { + for i, name := range dp.CustomerNames { + if name != "" { + result = append(result, fmt.Sprintf("%d: %s", i+1, name)) + } + } + } + return result +} + +// Helper method to get only customer names +func (dp *DevicePort) GetCustomerNames() []string { + if dp.Device.DeviceType == "OTB" { + return []string{} // OTB devices do not have port assignments + } + var result []string + for _, assignment := range dp.PortAssignments { + if assignment.CustomerName != nil && *assignment.CustomerName != "" { + result = append(result, *assignment.CustomerName) + } + } + // If no port assignments, fall back to customer names + if len(result) == 0 { + for _, name := range dp.CustomerNames { + if name != "" { + result = append(result, name) + } + } + } + return result +} + +func (dp *DevicePort) GetPortAssignmentsWithDetails() []PortAssignmentResponse { + // If port_used is 0, return empty array + + if dp.Device.DeviceType == "OTB" { + return []PortAssignmentResponse{} + } + + if dp.PortUsed == 0 { + return []PortAssignmentResponse{} + } + + var result []PortAssignmentResponse + + // Step 1: Create a map of all port assignments + portMap := make(map[int]*PortAssignment) + maxPort := 0 + + if len(dp.PortAssignments) > 0 { + for _, assignment := range dp.PortAssignments { + portMap[assignment.PortNumber] = &assignment + if assignment.PortNumber > maxPort { + maxPort = assignment.PortNumber + } + } + } else { + // Fallback: use CustomerNames array if PortAssignments is empty + for i, name := range dp.CustomerNames { + portNum := i + 1 + if name != "" || portNum <= dp.PortUsed { + var customerName *string + if name != "" { + customerName = &name + } + assignment := PortAssignment{ + PortNumber: portNum, + CustomerName: customerName, + Status: PortStatusOn, // Default status + Bandwidth: nil, // Default bandwidth + } + portMap[portNum] = &assignment + if portNum > maxPort { + maxPort = portNum + } + } + } + } + + // Ensure maxPort is at least equal to port_used + if maxPort < dp.PortUsed { + maxPort = dp.PortUsed + } + + // Step 2: Determine which ports should be occupied + portsWithCustomers := make([]int, 0) + for portNum, assignment := range portMap { + if assignment.CustomerName != nil && *assignment.CustomerName != "" { + portsWithCustomers = append(portsWithCustomers, portNum) + } + } + + // Sort to get them in order + sort.Ints(portsWithCustomers) + + // Step 3: Determine occupied ports + occupiedPorts := make(map[int]bool) + + // All ports with customers are occupied + for _, portNum := range portsWithCustomers { + occupiedPorts[portNum] = true + } + + // Fill remaining slots to reach port_used + occupiedCount := len(portsWithCustomers) + if occupiedCount < dp.PortUsed { + // Need to occupy more ports (ports without customer names but still occupied) + for i := 1; i <= maxPort && occupiedCount < dp.PortUsed; i++ { + if !occupiedPorts[i] { + occupiedPorts[i] = true + occupiedCount++ + } + } + } + + // Step 4: Create the result for ALL ports up to maxPort + for i := 1; i <= maxPort; i++ { + var customerName *string + var status PortStatus = PortStatusOff // Default status for unoccupied ports + var bandwidth *string + + if assignment := portMap[i]; assignment != nil { + customerName = assignment.CustomerName + status = assignment.Status + bandwidth = assignment.Bandwidth + } else if occupiedPorts[i] { + // Port is occupied but no assignment data - use defaults + status = PortStatusOn + } + + result = append(result, PortAssignmentResponse{ + PortNumber: i, + CustomerName: customerName, + IsOccupied: occupiedPorts[i], + Status: status, + Bandwidth: bandwidth, + }) + } + + return result +} +func (dp *DevicePort) GetCustomerNamesOnly() []string { + if dp.Device.DeviceType == "OTB" { + return []string{} // OTB devices do not have port assignments + } + var result []string + + // If we have PortAssignments data, use it + if len(dp.PortAssignments) > 0 { + for _, assignment := range dp.PortAssignments { + if assignment.CustomerName != nil && *assignment.CustomerName != "" { + result = append(result, *assignment.CustomerName) + } + } + } else { + // Fallback: use CustomerNames directly + for _, name := range dp.CustomerNames { + if name != "" { + result = append(result, name) + } + } + } + + return result } func (DevicePort) TableName() string { - return "device_ports" + return "device_ports" } \ No newline at end of file diff --git a/model/entity/device_with_distance.go b/model/entity/device_with_distance.go new file mode 100644 index 0000000..52751df --- /dev/null +++ b/model/entity/device_with_distance.go @@ -0,0 +1,25 @@ +package entity + +import ( + "time" + "github.com/google/uuid" +) + +// DeviceWithDistance represents a device with calculated distance +type DeviceWithDistance struct { + ID uuid.UUID `json:"id"` + DeviceCode string `json:"device_code"` + DeviceType DeviceType `json:"device_type"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + PortAmount int `json:"port_amount"` + Status DeviceStatus `json:"status"` + Region *string `json:"region,omitempty"` + Province *string `json:"province,omitempty"` + City *string `json:"city,omitempty"` + District *string `json:"district,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Distance float64 `json:"distance"` // Calculated distance +} \ No newline at end of file diff --git a/model/entity/devices.go b/model/entity/devices.go index 1e36fff..9741fe1 100644 --- a/model/entity/devices.go +++ b/model/entity/devices.go @@ -1,29 +1,79 @@ package entity import ( - "time" - - "github.com/google/uuid" + "time" + "github.com/google/uuid" ) type DeviceType string type DeviceStatus string const ( - Odp DeviceType = "ODP" - activeDev DeviceStatus = "active" - inactiveDev DeviceStatus = "inactive" - maintenanceDev DeviceStatus = "maintenance" + ODP DeviceType = "ODP" + OTB DeviceType = "OTB" + + Closure DeviceType = "closure" + + ActiveDev DeviceStatus = "active" + InactiveDev DeviceStatus = "inactive" + MaintenanceDev DeviceStatus = "maintenance" ) type Device struct { - ID uuid.UUID `json:"id"` - DeviceCode string `json:"device_code"` - DeviceType DeviceType `json:"device_type"` - Longitude float64 `json:"longitude"` - Latitude float64 `json:"latitude"` - PortAmount int `json:"port_amount"` - Status DeviceStatus `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + DeviceCode string `json:"device_code" gorm:"unique"` + DeviceType DeviceType `json:"device_type"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + PortAmount int `json:"port_amount"` + Status DeviceStatus `json:"status"` + Province *string `json:"province,omitempty" gorm:"type:varchar(255)"` + 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 + OLTID *uuid.UUID `json:"olt_id,omitempty" gorm:"type:uuid"` // Add this field + TowerID *uuid.UUID `json:"tower_id,omitempty" gorm:"type:uuid"` // Add TowerID field + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + OLT *OLT `json:"olt,omitempty" gorm:"foreignKey:OLTID;references:ID"` + Tower *Tower `json:"tower,omitempty" gorm:"foreignKey:TowerID;references:ID"` +} + +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/fishbone.go b/model/entity/fishbone.go index 59a3194..788d7aa 100644 --- a/model/entity/fishbone.go +++ b/model/entity/fishbone.go @@ -1,27 +1,26 @@ package entity import ( - "time" - - "github.com/google/uuid" + "time" + "github.com/google/uuid" ) type Fishbone struct { - ID uuid.UUID `json:"id"` - BackboneID uuid.UUID `json:"backbone_id" gorm:"column:bb_id"` - DeviceStartID uuid.UUID `json:"dev_start_id" gorm:"column:dev_start_id"` - DeviceEndID uuid.UUID `json:"dev_end_id" gorm:"column:dev_end_id"` - CoreAmount int `json:"core_amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + FishboneCode string `json:"fishbone_code" gorm:"unique"` + BackboneID uuid.UUID `json:"bb_id" gorm:"type:uuid;column:bb_id"` + DeviceStartID uuid.UUID `json:"dev_start_id" gorm:"type:uuid;column:dev_start_id"` + DeviceEndID uuid.UUID `json:"dev_end_id" gorm:"type:uuid;column:dev_end_id"` + CoreAmount int `json:"core_amount" gorm:"column:core_amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` - DeviceStart Device `gorm:"foreignKey:DeviceStartID"` - DeviceEnd Device `gorm:"foreignKey:DeviceEndID"` - - Backbone Backbone `gorm:"foreignKey:BackboneID"` - + // Relationships + Backbone Backbone `json:"backbone" gorm:"foreignKey:BackboneID"` + DeviceStart Device `json:"device_start" gorm:"foreignKey:DeviceStartID"` + DeviceEnd Device `json:"device_end" gorm:"foreignKey:DeviceEndID"` } func (Fishbone) TableName() string { - return "fishbone" + return "fishbone" } \ No newline at end of file diff --git a/model/entity/olt.go b/model/entity/olt.go new file mode 100644 index 0000000..91a604d --- /dev/null +++ b/model/entity/olt.go @@ -0,0 +1,20 @@ +package entity + +import ( + "time" + "github.com/google/uuid" +) + +type OLT struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + OLTName string `json:"olt_name" gorm:"unique;not null"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relationships - One OLT can have many devices + Devices []Device `json:"devices,omitempty" gorm:"foreignKey:OLTID;references:ID"` +} + +func (OLT) TableName() string { + return "olts" +} \ No newline at end of file diff --git a/model/entity/roles.go b/model/entity/roles.go index ee6089f..f5264df 100644 --- a/model/entity/roles.go +++ b/model/entity/roles.go @@ -1,10 +1,18 @@ package entity -import "github.com/google/uuid" +import ( + "time" + + "github.com/google/uuid" +) type Role struct { - Id uuid.UUID `json:"id"` - Name string `json:"name"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + Id uuid.UUID `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` + Name string `json:"name" gorm:"type:varchar(100);not null"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Role) TableName() string { + return "roles" } \ No newline at end of file diff --git a/model/entity/tower.go b/model/entity/tower.go index 69a0315..1ba0f93 100644 --- a/model/entity/tower.go +++ b/model/entity/tower.go @@ -1,21 +1,74 @@ +// Update model/entity/tower.go package entity import ( - "time" - - "github.com/google/uuid" + "time" + "github.com/google/uuid" ) type Tower struct { - ID uuid.UUID `json:"id"` - DeviceID uuid.UUID`json:"dev_id" gorm:"column:dev_id"` - TowerCode string `json:"tower_code"` - Longitude float64 `json:"longitude"` - Latitude float64 `json:"latitude"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + DeviceID *uuid.UUID `json:"dev_id,omitempty" gorm:"type:uuid;column:dev_id;null"` // Make nullable + TowerCode string `json:"tower_code" gorm:"unique"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + 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 `gorm:"foreignKey:DeviceID"` +type TowerWithDistance struct { + ID uuid.UUID `json:"id"` + TowerCode string `json:"tower_code"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + ImageURL *string `json:"image_url"` + ExternalTower *bool `json:"external_tower"` + DevID *uuid.UUID `json:"dev_id"` + DeviceCode *string `json:"device_code"` + Province *string `json:"province"` + City *string `json:"city"` + District *string `json:"district"` + Distance float64 `json:"distance"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// 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/model/entity/tower_inspection.go b/model/entity/tower_inspection.go new file mode 100644 index 0000000..86f827f --- /dev/null +++ b/model/entity/tower_inspection.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/google/uuid" + +type TowerInspection struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + TowerID uuid.UUID `json:"tower_id" gorm:"type:uuid;column:tower_id"` + UserID uuid.UUID `json:"user_id" gorm:"type:uuid;column:user_id"` + Description string `json:"description" gorm:"type:text;column:description"` + ImageURL string `json:"image_url" gorm:"type:text;column:image_url"` + CreatedAt string `json:"created_at" gorm:"type:timestamp;column:created_at"` + UpdatedAt string `json:"updated_at" gorm:"type:timestamp;column:updated_at"` +} \ No newline at end of file diff --git a/model/entity/users.go b/model/entity/users.go index 9a962cb..c0a2798 100644 --- a/model/entity/users.go +++ b/model/entity/users.go @@ -1,18 +1,33 @@ package entity import ( - "time" + "time" - "github.com/google/uuid" + "github.com/google/uuid" +) + +type UserStatus string + +const ( + UserStatusPending UserStatus = "pending" + UserStatusApproved UserStatus = "approved" + UserStatusRejected UserStatus = "rejected" ) type User struct { - ID uuid.UUID `json:"id"` - RoleID uuid.UUID `json:"role_id"` - Role Role `json:"role"` - Name string `json:"name"` - Username string `json:"username"` - Password string `json:"password"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} \ No newline at end of file + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + NomorInduk *string `json:"nomor_induk,omitempty" gorm:"unique"` + RoleID uuid.UUID `json:"role_id" gorm:"type:uuid"` + Role Role `json:"role" gorm:"foreignKey:RoleID"` + Name string `json:"name" gorm:"unique"` + Username string `json:"username" gorm:"unique"` + Password string `json:"password"` + Status UserStatus `json:"status" gorm:"type:varchar(20);default:'pending'"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (User) TableName() string { + return "users" +} + diff --git a/repository/activity_logs_repo.go b/repository/activity_logs_repo.go new file mode 100644 index 0000000..4f51bde --- /dev/null +++ b/repository/activity_logs_repo.go @@ -0,0 +1,90 @@ +package repository + +import ( + "users_management/m/model/entity" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ActivityLogRepo interface { + Create(log entity.ActivityLog) error + GetByUserID(userID uuid.UUID, limit, offset int) ([]entity.ActivityLog, error) + GetAllForAdmins(limit, offset int) ([]entity.ActivityLog, error) + GetByUserRole(role string, limit, offset int) ([]entity.ActivityLog, error) + CountByUserID(userID uuid.UUID) (int64, error) + CountAll() (int64, error) + CountByUserRole(role string) (int64, error) +} + +type activityLogRepo struct { + db *gorm.DB +} + +func NewActivityLogRepo(db *gorm.DB) ActivityLogRepo { + return &activityLogRepo{ + db: db, + } +} + +func (r *activityLogRepo) Create(log entity.ActivityLog) error { + return r.db.Create(&log).Error +} + +func (r *activityLogRepo) GetByUserID(userID uuid.UUID, limit, offset int) ([]entity.ActivityLog, error) { + var logs []entity.ActivityLog + err := r.db.Where("user_id = ?", userID). + Preload("User"). + Preload("User.Role"). + Order("timestamp DESC"). + Limit(limit). + Offset(offset). + Find(&logs).Error + return logs, err +} + +func (r *activityLogRepo) GetAllForAdmins(limit, offset int) ([]entity.ActivityLog, error) { + var logs []entity.ActivityLog + err := r.db.Preload("User"). + Preload("User.Role"). + Order("timestamp DESC"). + Limit(limit). + Offset(offset). + Find(&logs).Error + return logs, err +} + +func (r *activityLogRepo) GetByUserRole(role string, limit, offset int) ([]entity.ActivityLog, error) { + var logs []entity.ActivityLog + err := r.db.Joins("JOIN users ON users.id = activity_logs.user_id"). + Joins("JOIN roles ON roles.id = users.role_id"). + Where("roles.name = ?", role). + Preload("User"). + Preload("User.Role"). + Order("activity_logs.timestamp DESC"). + Limit(limit). + Offset(offset). + Find(&logs).Error + return logs, err +} + +func (r *activityLogRepo) CountByUserID(userID uuid.UUID) (int64, error) { + var count int64 + err := r.db.Model(&entity.ActivityLog{}).Where("user_id = ?", userID).Count(&count).Error + return count, err +} + +func (r *activityLogRepo) CountAll() (int64, error) { + var count int64 + err := r.db.Model(&entity.ActivityLog{}).Count(&count).Error + return count, err +} + +func (r *activityLogRepo) CountByUserRole(role string) (int64, error) { + var count int64 + err := r.db.Model(&entity.ActivityLog{}). + Joins("JOIN users ON users.id = activity_logs.user_id"). + Joins("JOIN roles ON roles.id = users.role_id"). + Where("roles.name = ?", role). + Count(&count).Error + return count, err +} \ No newline at end of file diff --git a/repository/backbone_repo.go b/repository/backbone_repo.go index c06850d..2bcdf3e 100644 --- a/repository/backbone_repo.go +++ b/repository/backbone_repo.go @@ -11,8 +11,8 @@ type BackboneRepo interface { Post(backbone entity.Backbone) error GetAll() ([]entity.Backbone, error) Update(id uuid.UUID, updates map[string]interface{}) error - GetByID(id uuid.UUID) (entity.Backbone, error) + WithTransaction(fn func(*gorm.DB) error) error } type backboneRepo struct { @@ -59,3 +59,7 @@ func (r *backboneRepo) GetByID(id uuid.UUID) (entity.Backbone, error) { } return backbone, nil } + +func (r *backboneRepo) WithTransaction(fn func(*gorm.DB) error) error { + return r.db.Transaction(fn) +} \ No newline at end of file diff --git a/repository/count_assets.go b/repository/count_assets.go new file mode 100644 index 0000000..5b0165b --- /dev/null +++ b/repository/count_assets.go @@ -0,0 +1,136 @@ +package repository + +import ( + "users_management/m/model/entity" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type CountAssetsRepo interface { + Get() (entity.CountAssets, error) + UpdateCounts() error +} + + +type countAssetsRepo struct { + db *gorm.DB +} + + +func NewCountAssetsRepo(db *gorm.DB) CountAssetsRepo { + return &countAssetsRepo{ + db: db, + } +} + +func (r *countAssetsRepo) Get() (entity.CountAssets, error) { + var countAssets entity.CountAssets + + // Try to get existing record, if not found create a new one + err := r.db.First(&countAssets).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + // Create new record with current counts + return r.createNewCountRecord() + } + return countAssets, err + } + + return countAssets, nil +} + +func (r *countAssetsRepo) UpdateCounts() error { + var countAssets entity.CountAssets + + // Get current counts from each table + fishboneCount, err := r.countFishbones() + if err != nil { + return err + } + + backboneCount, err := r.countBackbones() + if err != nil { + return err + } + + deviceCount, err := r.countDevices() + if err != nil { + return err + } + + towerCount, err := r.countTowers() + if err != nil { + return err + } + + // Check if record exists + err = r.db.First(&countAssets).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + // Create new record + countAssets = entity.CountAssets{ + ID: uuid.New(), + FishboneAmount: fishboneCount, + BackboneAmount: backboneCount, + DeviceAmount: deviceCount, + TowerAmount: towerCount, + } + return r.db.Create(&countAssets).Error + } + return err + } + + // Update existing record + updates := map[string]interface{}{ + "fishbone_amount": fishboneCount, + "backbone_amount": backboneCount, + "device_amount": deviceCount, + "tower_amount": towerCount, + } + + return r.db.Model(&countAssets).Updates(updates).Error +} + +func (r *countAssetsRepo) createNewCountRecord() (entity.CountAssets, error) { + fishboneCount, _ := r.countFishbones() + backboneCount, _ := r.countBackbones() + deviceCount, _ := r.countDevices() + towerCount, _ := r.countTowers() + + countAssets := entity.CountAssets{ + ID: uuid.New(), + FishboneAmount: fishboneCount, + BackboneAmount: backboneCount, + DeviceAmount: deviceCount, + TowerAmount: towerCount, + } + + err := r.db.Create(&countAssets).Error + return countAssets, err +} + +// Helper methods to count each entity +func (r *countAssetsRepo) countFishbones() (int, error) { + var count int64 + err := r.db.Model(&entity.Fishbone{}).Count(&count).Error + return int(count), err +} + +func (r *countAssetsRepo) countBackbones() (int, error) { + var count int64 + err := r.db.Model(&entity.Backbone{}).Count(&count).Error + return int(count), err +} + +func (r *countAssetsRepo) countDevices() (int, error) { + var count int64 + err := r.db.Model(&entity.Device{}).Count(&count).Error + return int(count), err +} + +func (r *countAssetsRepo) countTowers() (int, error) { + var count int64 + err := r.db.Model(&entity.Tower{}).Count(&count).Error + return int(count), err +} \ No newline at end of file diff --git a/repository/device_details.go b/repository/device_details.go new file mode 100644 index 0000000..b0c4f6c --- /dev/null +++ b/repository/device_details.go @@ -0,0 +1,1124 @@ +package repository + +import ( + "fmt" + "time" + "users_management/m/model/dto/req" + "users_management/m/model/entity" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type DeviceDetailsRepo interface { + Create(device entity.Device) error + GetAll() ([]entity.DeviceDetails, error) + GetByID(id uuid.UUID) (entity.DeviceDetails, error) + Update(id uuid.UUID, updates map[string]interface{}) error + Delete(id uuid.UUID) error + + // Port management + UpdateDevicePortUsage(deviceID uuid.UUID) error + // ValidatePortAvailability(deviceID uuid.UUID, requiredPorts int) error + + // Connection management + GetBackbonesByDeviceID(deviceID uuid.UUID) ([]entity.Backbone, error) + GetFishbonesByDeviceID(deviceID uuid.UUID) ([]entity.Fishbone, error) + GetTowersByDeviceID(deviceID uuid.UUID) ([]entity.Tower, error) + + // Validation helpers + GetPortUsageByDevice(deviceID uuid.UUID) (portUsed, portAvailable int, err error) + AssignCustomerToPort(deviceID uuid.UUID, customerName string, portNumber *int) error + RemoveCustomerFromPort(deviceID uuid.UUID, customerName string) error + UpdatePortUsage(deviceID uuid.UUID, portUsed int) error + UpdatePortAssignments(deviceID uuid.UUID, assignments []req.PortAssignmentDTO) error + MigrateCustomerNamesToPortAssignments(devicePort *entity.DevicePort, devicePortAmount int) error + AssignMultipleCustomersToPort(deviceID uuid.UUID, assignments []req.AssignMultipleCustomersDTO) error + UpdateCustomerByPort(deviceID uuid.UUID, update req.UpdateCustomerByPortDTO) error + BulkUpdateCustomersByPort(deviceID uuid.UUID, updates []req.UpdateCustomerByPortDTO) error + RemoveCustomerByPort(deviceID uuid.UUID, portNumber int) error + GetDevicesWithoutTowers(deviceTypes []string) ([]entity.DeviceDetails, error) + GetDevicesWithoutConnections(deviceTypes []string) ([]entity.DeviceDetails, error) + ValidateTowerExists(towerID uuid.UUID) (bool, error) + SyncTowerLocationWithDevice(deviceID uuid.UUID) error + + +} + +type deviceDetailsRepo struct { + db *gorm.DB +} + +func NewDeviceDetailsRepo(db *gorm.DB) DeviceDetailsRepo { + return &deviceDetailsRepo{ + db: db, + } +} + +func (r *deviceDetailsRepo) ValidateTowerExists(towerID uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.Tower{}).Where("id = ?", towerID).Count(&count).Error + return count > 0, err +} + +func (r *deviceDetailsRepo) SyncTowerLocationWithDevice(deviceID uuid.UUID) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Get device details + var device entity.Device + if err := tx.Where("id = ?", deviceID).First(&device).Error; err != nil { + return fmt.Errorf("device not found: %w", err) + } + + // Update tower location if device has a tower assigned + if device.TowerID != nil { + if err := tx.Model(&entity.Tower{}). + Where("id = ?", *device.TowerID). + Updates(map[string]interface{}{ + "longitude": device.Longitude, + "latitude": device.Latitude, + "updated_at": time.Now(), + }).Error; err != nil { + return fmt.Errorf("failed to sync tower location: %w", err) + } + } + + // Also update any towers that reference this device via dev_id + if err := tx.Model(&entity.Tower{}). + Where("dev_id = ?", deviceID). + Updates(map[string]interface{}{ + "longitude": device.Longitude, + "latitude": device.Latitude, + "updated_at": time.Now(), + }).Error; err != nil { + return fmt.Errorf("failed to sync related towers location: %w", err) + } + + return nil + }) +} + +func (r *deviceDetailsRepo) GetDevicesWithoutConnections(deviceTypes []string) ([]entity.DeviceDetails, error) { + var devices []entity.DeviceDetails + + query := r.db.Preload("DevicePort") + + // Filter by device types + if len(deviceTypes) > 0 { + query = query.Where("device_type IN ?", deviceTypes) + } + + // For closure devices: exclude those that have fishbones (as start device) + // For OTB devices: exclude those that have backbones (as start or end device) or fishbones (as end device) + subQueryBackbone := r.db.Table("backbone"). + Select("DISTINCT CASE WHEN dev_start_id IS NOT NULL THEN dev_start_id ELSE dev_end_id END as device_id"). + Where("dev_start_id IS NOT NULL OR dev_end_id IS NOT NULL") + + subQueryFishbone := r.db.Table("fishbone"). + Select("DISTINCT CASE WHEN dev_start_id IS NOT NULL THEN dev_start_id ELSE dev_end_id END as device_id"). + Where("dev_start_id IS NOT NULL OR dev_end_id IS NOT NULL") + + // Combine both subqueries to exclude devices that have any connections + query = query.Where("id NOT IN (?)", + r.db.Raw("(?) UNION (?)", subQueryBackbone, subQueryFishbone)) + + err := query.Find(&devices).Error + return devices, err +} + +func (r *deviceDetailsRepo) UpdateCustomerByPort(deviceID uuid.UUID, update req.UpdateCustomerByPortDTO) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Lock both device and device_port records + var device entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", deviceID).First(&device).Error; err != nil { + return err + } + + // Only ODP devices can have customer assignments + if device.DeviceType != "ODP" { + return fmt.Errorf("customer assignments can only be updated for ODP devices") + } + + var devicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return fmt.Errorf("device port record not found: %w", err) + } + + // Initialize port assignments if empty + if len(devicePort.PortAssignments) == 0 { + devicePort.PortAssignments = make(entity.PortAssignments, device.PortAmount) + for i := 0; i < device.PortAmount; i++ { + devicePort.PortAssignments[i] = entity.PortAssignment{ + PortNumber: i + 1, + CustomerName: nil, + Status: entity.PortStatusOff, + Bandwidth: nil, + } + } + } + + // Validate port number + if update.PortNumber > device.PortAmount { + return fmt.Errorf("port number %d exceeds device capacity (%d)", update.PortNumber, device.PortAmount) + } + + portIndex := update.PortNumber - 1 + + // If assigning a new customer name + if update.NewCustomerName != nil && *update.NewCustomerName != "" { + // Check if customer name already exists on another port + for i, assignment := range devicePort.PortAssignments { + if i != portIndex && assignment.CustomerName != nil && *assignment.CustomerName == *update.NewCustomerName { + return fmt.Errorf("customer %s is already assigned to port %d", *update.NewCustomerName, i+1) + } + } + } + + if update.NewCustomerName == nil { + // Remove customer from port + devicePort.PortAssignments[portIndex].CustomerName = nil + devicePort.PortAssignments[portIndex].Status = entity.PortStatusOff + devicePort.PortAssignments[portIndex].Bandwidth = nil + } else { + // Assign/update customer on port + devicePort.PortAssignments[portIndex].CustomerName = update.NewCustomerName + + // Update status if provided, otherwise default to "on" + if update.Status != nil { + devicePort.PortAssignments[portIndex].Status = *update.Status + } else { + devicePort.PortAssignments[portIndex].Status = entity.PortStatusOn + } + + // Update bandwidth if provided + if update.Bandwidth != nil { + devicePort.PortAssignments[portIndex].Bandwidth = update.Bandwidth + } + } + + // Update counters and backward compatibility fields + r.updateDevicePortCounters(&devicePort) + devicePort.UpdatedAt = time.Now() + + return tx.Save(&devicePort).Error + }) +} + +func (r *deviceDetailsRepo) BulkUpdateCustomersByPort(deviceID uuid.UUID, updates []req.UpdateCustomerByPortDTO) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Lock both device and device_port records + var device entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", deviceID).First(&device).Error; err != nil { + return err + } + + // Only ODP devices can have customer assignments + if device.DeviceType != "ODP" { + return fmt.Errorf("customer assignments can only be updated for ODP devices") + } + + var devicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return fmt.Errorf("device port record not found: %w", err) + } + + // Initialize port assignments if empty + if len(devicePort.PortAssignments) == 0 { + devicePort.PortAssignments = make(entity.PortAssignments, device.PortAmount) + for i := 0; i < device.PortAmount; i++ { + devicePort.PortAssignments[i] = entity.PortAssignment{ + PortNumber: i + 1, + CustomerName: nil, + Status: entity.PortStatusOff, + Bandwidth: nil, + } + } + } + + // Validate all port numbers first + for _, update := range updates { + if update.PortNumber > device.PortAmount { + return fmt.Errorf("port number %d exceeds device capacity (%d)", update.PortNumber, device.PortAmount) + } + } + + // Create a map of final assignments to validate for duplicates + finalAssignments := make(map[int]*string) + + // Start with current assignments + for i, assignment := range devicePort.PortAssignments { + finalAssignments[i+1] = assignment.CustomerName + } + + // Apply updates + for _, update := range updates { + finalAssignments[update.PortNumber] = update.NewCustomerName + } + + // Check for duplicate customer names + customerNames := make(map[string]int) // customer name -> port number + for portNum, customerName := range finalAssignments { + if customerName != nil && *customerName != "" { + if existingPort, exists := customerNames[*customerName]; exists { + return fmt.Errorf("customer %s would be assigned to both port %d and port %d", *customerName, existingPort, portNum) + } + customerNames[*customerName] = portNum + } + } + + // Apply all updates - FIX: Include Status and Bandwidth updates + for _, update := range updates { + portIndex := update.PortNumber - 1 + + // Update customer name + devicePort.PortAssignments[portIndex].CustomerName = update.NewCustomerName + + // Update status if provided + if update.Status != nil { + devicePort.PortAssignments[portIndex].Status = *update.Status + } else { + // Set default status based on customer assignment + if update.NewCustomerName != nil && *update.NewCustomerName != "" { + devicePort.PortAssignments[portIndex].Status = entity.PortStatusOn + } else { + devicePort.PortAssignments[portIndex].Status = entity.PortStatusOff + } + } + + // Update bandwidth if provided + if update.Bandwidth != nil { + devicePort.PortAssignments[portIndex].Bandwidth = update.Bandwidth + } else if update.NewCustomerName == nil || *update.NewCustomerName == "" { + // Clear bandwidth when removing customer + devicePort.PortAssignments[portIndex].Bandwidth = nil + } + } + + // Recalculate port_used based on actual assignments + highestUsedPort := 0 + customerCount := 0 + + for _, assignment := range devicePort.PortAssignments { + if assignment.CustomerName != nil && *assignment.CustomerName != "" { + customerCount++ + if assignment.PortNumber > highestUsedPort { + highestUsedPort = assignment.PortNumber + } + } + } + + // Update port_used to reflect actual usage + if customerCount == 0 { + devicePort.PortUsed = 0 + } else { + // Set port_used to the highest port with a customer + // or keep current port_used if it's higher (for reserved ports) + if highestUsedPort > devicePort.PortUsed { + devicePort.PortUsed = highestUsedPort + } + } + + // Recalculate port_available + devicePort.PortAvailable = device.PortAmount - devicePort.PortUsed + + // Update counters and backward compatibility fields + r.updateDevicePortCounters(&devicePort) + devicePort.UpdatedAt = time.Now() + + return tx.Save(&devicePort).Error + }) +} + +func (r *deviceDetailsRepo) RemoveCustomerByPort(deviceID uuid.UUID, portNumber int) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var device entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", deviceID).First(&device).Error; err != nil { + return err + } + + var devicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return fmt.Errorf("device port record not found: %w", err) + } + + // Validate port number + if portNumber > device.PortAmount { + return fmt.Errorf("port number %d exceeds device capacity (%d)", portNumber, device.PortAmount) + } + + portIndex := portNumber - 1 + + // Check if port has a customer + if len(devicePort.PortAssignments) <= portIndex || + devicePort.PortAssignments[portIndex].CustomerName == nil || + *devicePort.PortAssignments[portIndex].CustomerName == "" { + return fmt.Errorf("port %d does not have a customer assigned", portNumber) + } + + // Remove customer from port + devicePort.PortAssignments[portIndex].CustomerName = nil + + // Update counters and backward compatibility fields + r.updateDevicePortCounters(&devicePort) + devicePort.UpdatedAt = time.Now() + + + return tx.Save(&devicePort).Error + }) +} + +func (r *deviceDetailsRepo) UpdatePortAssignments(deviceID uuid.UUID, assignments []req.PortAssignmentDTO) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Lock both device and device_port records + var device entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", deviceID).First(&device).Error; err != nil { + return err + } + + // Only ODP devices can have port assignments + if device.DeviceType != "ODP" { + return fmt.Errorf("port assignments can only be updated for ODP devices") + } + + var devicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return fmt.Errorf("device port record not found: %w", err) + } + + // Validate port numbers are within device capacity + for _, assignment := range assignments { + if assignment.PortNumber < 1 || assignment.PortNumber > device.PortAmount { + return fmt.Errorf("port number %d is out of range (1-%d)", assignment.PortNumber, device.PortAmount) + } + } + + // Initialize port assignments array if needed + if len(devicePort.PortAssignments) == 0 { + devicePort.PortAssignments = make(entity.PortAssignments, device.PortAmount) + for i := 0; i < device.PortAmount; i++ { + devicePort.PortAssignments[i] = entity.PortAssignment{ + PortNumber: i + 1, + CustomerName: nil, + Status: entity.PortStatusOff, + Bandwidth: nil, + } + } + } + + // Check for duplicate customer names (if not null) + customerNames := make(map[string]int) // map customer name to port number + for _, assignment := range assignments { + if assignment.CustomerName != nil && *assignment.CustomerName != "" { + if existingPort, exists := customerNames[*assignment.CustomerName]; exists { + return fmt.Errorf("customer %s is assigned to multiple ports (%d and %d)", + *assignment.CustomerName, existingPort, assignment.PortNumber) + } + customerNames[*assignment.CustomerName] = assignment.PortNumber + } + } + + // Update port assignments + for _, assignment := range assignments { + portIndex := assignment.PortNumber - 1 + if portIndex < len(devicePort.PortAssignments) { + devicePort.PortAssignments[portIndex].PortNumber = assignment.PortNumber + devicePort.PortAssignments[portIndex].CustomerName = assignment.CustomerName + + // Update status if provided, otherwise default based on customer presence + if assignment.Status != nil { + devicePort.PortAssignments[portIndex].Status = *assignment.Status + } else if assignment.CustomerName != nil && *assignment.CustomerName != "" { + devicePort.PortAssignments[portIndex].Status = entity.PortStatusOn + } else { + devicePort.PortAssignments[portIndex].Status = entity.PortStatusOff + } + + // Update bandwidth if provided + if assignment.Bandwidth != nil { + devicePort.PortAssignments[portIndex].Bandwidth = assignment.Bandwidth + } + } + } + + // Update counters and backward compatibility fields + r.updateDevicePortCounters(&devicePort) + + // Calculate port usage based on assignments + portUsed := 0 + for _, assignment := range devicePort.PortAssignments { + if assignment.CustomerName != nil && *assignment.CustomerName != "" { + portUsed++ + } + } + + devicePort.PortUsed = portUsed + devicePort.PortAvailable = device.PortAmount - portUsed + devicePort.UpdatedAt = time.Now() + + return tx.Save(&devicePort).Error + }) +} + + +func (r *deviceDetailsRepo) Create(device entity.Device) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Validate tower exists if TowerID is provided + if device.TowerID != nil { + var towerExists bool + var err error + if towerExists, err = r.ValidateTowerExists(*device.TowerID); err != nil { + return fmt.Errorf("failed to validate tower: %w", err) + } + if !towerExists { + return fmt.Errorf("tower with ID %s not found", device.TowerID.String()) + } + } + + // Create device + if err := tx.Create(&device).Error; err != nil { + return err + } + + // Create corresponding device port + devicePort := entity.DevicePort{ + ID: uuid.New(), + DeviceID: device.ID, + PortUsed: 0, + PortAvailable: device.PortAmount, + CreatedAt: device.CreatedAt, + UpdatedAt: device.UpdatedAt, + } + + if err := tx.Create(&devicePort).Error; err != nil { + return err + } + + // Sync tower location with device location + if device.TowerID != nil { + if err := tx.Model(&entity.Tower{}). + Where("id = ?", *device.TowerID). + Updates(map[string]interface{}{ + "longitude": device.Longitude, + "latitude": device.Latitude, + "updated_at": time.Now(), + }).Error; err != nil { + return fmt.Errorf("failed to sync tower location: %w", err) + } + } + + return nil + }) +} + +func (r *deviceDetailsRepo) GetAll() ([]entity.DeviceDetails, error) { + var devices []entity.DeviceDetails + err := r.db. + Preload("DevicePort"). + Preload("BackbonesStart"). + Preload("BackbonesStart.DeviceStart"). + Preload("BackbonesStart.DeviceEnd"). + Preload("BackbonesEnd"). + Preload("BackbonesEnd.DeviceStart"). + Preload("BackbonesEnd.DeviceEnd"). + Preload("FishbonesStart"). + Preload("FishbonesStart.DeviceStart"). + Preload("FishbonesStart.DeviceEnd"). + Preload("FishbonesStart.Backbone"). + Preload("FishbonesEnd"). + Preload("FishbonesEnd.DeviceStart"). + Preload("FishbonesEnd.DeviceEnd"). + Preload("FishbonesEnd.Backbone"). + Preload("OLT"). + Preload("Tower"). // Add Tower preload + Preload("Towers"). + Preload("Towers.Device"). + Find(&devices).Error + return devices, err +} + +func (r *deviceDetailsRepo) GetByID(id uuid.UUID) (entity.DeviceDetails, error) { + var device entity.DeviceDetails + err := r.db. + Preload("DevicePort"). + Preload("BackbonesStart"). + Preload("BackbonesStart.DeviceStart"). + Preload("BackbonesStart.DeviceEnd"). + Preload("BackbonesEnd"). + Preload("BackbonesEnd.DeviceStart"). + Preload("BackbonesEnd.DeviceEnd"). + Preload("FishbonesStart"). + Preload("FishbonesStart.DeviceStart"). + Preload("FishbonesStart.DeviceEnd"). + Preload("FishbonesStart.Backbone"). + Preload("FishbonesEnd"). + Preload("FishbonesEnd.DeviceStart"). + Preload("FishbonesEnd.DeviceEnd"). + Preload("FishbonesEnd.Backbone"). + Preload("OLT"). + Preload("Tower"). // Add Tower preload + Preload("Towers"). + Preload("Towers.Device"). + Where("id = ?", id). + First(&device).Error + return device, err +} + +func (r *deviceDetailsRepo) Update(id uuid.UUID, updates map[string]interface{}) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Update device + if err := tx.Model(&entity.Device{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return err + } + + // If TowerID is updated, sync locations + if towerID, exists := updates["tower_id"]; exists && towerID != nil { + towerUUID := towerID.(uuid.UUID) + // Validate tower exists + var towerExists bool + var err error + if towerExists, err = r.ValidateTowerExists(towerUUID); err != nil { + return fmt.Errorf("failed to validate tower: %w", err) + } + if !towerExists { + return fmt.Errorf("tower with ID %s not found", towerUUID.String()) + } + } + + // If port_amount is updated, update device_port + if portAmount, exists := updates["port_amount"]; exists { + if err := r.updatePortAmountCascade(tx, id, portAmount.(int)); err != nil { + return err + } + } + + // If location is updated, sync with tower + if _, hasLng := updates["longitude"]; hasLng { + if _, hasLat := updates["latitude"]; hasLat { + if err := r.SyncTowerLocationWithDevice(id); err != nil { + return err + } + } + } + + return nil + }) +} + +func (r *deviceDetailsRepo) GetDevicesWithoutTowers(deviceTypes []string) ([]entity.DeviceDetails, error) { + var devices []entity.DeviceDetails + + query := r.db. + Preload("DevicePort"). + Preload("Tower"). // Add Tower preload + Where("device_type IN ?", deviceTypes). + Where("tower_id IS NULL"). // Check for NULL TowerID instead of dev_id + Where("id NOT IN (?)", + r.db.Table("towers"). + Select("dev_id"). + Where("dev_id IS NOT NULL")) + + err := query.Find(&devices).Error + return devices, err +} + +func (r *deviceDetailsRepo) updatePortAmountCascade(tx *gorm.DB, deviceID uuid.UUID, newPortAmount int) error { + // Get current port usage + var devicePort entity.DevicePort + if err := tx.Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return err + } + + // Check if new port amount is sufficient for current usage + if newPortAmount < devicePort.PortUsed { + return fmt.Errorf("cannot reduce port amount to %d, currently using %d ports", newPortAmount, devicePort.PortUsed) + } + + // Special case: if port_amount is set to 0, clear all assignments + if newPortAmount == 0 { + devicePort.PortUsed = 0 + devicePort.PortAvailable = 0 + devicePort.CustomerCount = 0 + devicePort.CustomerNames = []string{} + devicePort.PortAssignments = entity.PortAssignments{} + devicePort.UpdatedAt = time.Now() + } else { + // Calculate new port available + newPortAvailable := newPortAmount - devicePort.PortUsed + + // Update port assignments to match new port amount + if len(devicePort.PortAssignments) > 0 { + // Resize port assignments array + newPortAssignments := make(entity.PortAssignments, newPortAmount) + + // Copy existing assignments up to the new port amount + for i := 0; i < newPortAmount; i++ { + if i < len(devicePort.PortAssignments) { + newPortAssignments[i] = devicePort.PortAssignments[i] + } else { + newPortAssignments[i] = entity.PortAssignment{ + PortNumber: i + 1, + CustomerName: nil, + } + } + } + devicePort.PortAssignments = newPortAssignments + } + + devicePort.PortAvailable = newPortAvailable + devicePort.UpdatedAt = time.Now() + } + + return tx.Save(&devicePort).Error +} + +func (r *deviceDetailsRepo) UpdatePortUsage(deviceID uuid.UUID, portUsed int) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var device entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", deviceID).First(&device).Error; err != nil { + return err + } + + if portUsed > device.PortAmount { + return fmt.Errorf("port_used (%d) cannot exceed port_amount (%d)", portUsed, device.PortAmount) + } + + var devicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return fmt.Errorf("device port record not found: %w", err) + } + + // Initialize port assignments if empty and we have customers or port_used > 0 + if len(devicePort.PortAssignments) == 0 && (len(devicePort.CustomerNames) > 0 || portUsed > 0) { + devicePort.PortAssignments = make(entity.PortAssignments, device.PortAmount) + for i := 0; i < device.PortAmount; i++ { + var customerName *string + // Map existing customer names to ports + if i < len(devicePort.CustomerNames) && devicePort.CustomerNames[i] != "" { + customerName = &devicePort.CustomerNames[i] + } + + devicePort.PortAssignments[i] = entity.PortAssignment{ + PortNumber: i + 1, + CustomerName: customerName, + } + } + } + + // Update port assignments based on new port_used value + currentCustomerCount := 0 + for _, assignment := range devicePort.PortAssignments { + if assignment.CustomerName != nil && *assignment.CustomerName != "" { + currentCustomerCount++ + } + } + + if portUsed < currentCustomerCount { + // Need to remove some customers (keep first N customers) + customersKept := 0 + for i := range devicePort.PortAssignments { + if devicePort.PortAssignments[i].CustomerName != nil && *devicePort.PortAssignments[i].CustomerName != "" { + if customersKept < portUsed { + customersKept++ + } else { + devicePort.PortAssignments[i].CustomerName = nil + } + } + } + } + + // Update counters + devicePort.PortUsed = portUsed + devicePort.PortAvailable = device.PortAmount - portUsed + r.updateDevicePortCounters(&devicePort) + devicePort.UpdatedAt = time.Now() + + return tx.Save(&devicePort).Error + }) +} + +func (r *deviceDetailsRepo) MigrateCustomerNamesToPortAssignments(devicePort *entity.DevicePort, devicePortAmount int) error { + // Only migrate if PortAssignments is empty but CustomerNames has data + if len(devicePort.PortAssignments) == 0 && len(devicePort.CustomerNames) > 0 { + devicePort.PortAssignments = make(entity.PortAssignments, devicePortAmount) + + for i := 0; i < devicePortAmount; i++ { + var customerName *string + if i < len(devicePort.CustomerNames) && devicePort.CustomerNames[i] != "" { + customerName = &devicePort.CustomerNames[i] + } + + devicePort.PortAssignments[i] = entity.PortAssignment{ + PortNumber: i + 1, + CustomerName: customerName, + } + } + } + return nil +} + +// func (r *deviceDetailsRepo) ValidatePortAvailability(deviceID uuid.UUID, requiredPorts int) error { +// var devicePort entity.DevicePort +// if err := r.db.Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { +// return err +// } + +// if devicePort.Portvailable < requiredPorts { +// return errors.New("insufficient available ports") +// } + +// return nil +// } + +func (r *deviceDetailsRepo) GetBackbonesByDeviceID(deviceID uuid.UUID) ([]entity.Backbone, error) { + var backbones []entity.Backbone + err := r.db.Preload("DeviceStart").Preload("DeviceEnd"). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Find(&backbones).Error + return backbones, err +} + +func (r *deviceDetailsRepo) GetFishbonesByDeviceID(deviceID uuid.UUID) ([]entity.Fishbone, error) { + var fishbones []entity.Fishbone + err := r.db.Preload("Backbone").Preload("DeviceStart").Preload("DeviceEnd"). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Find(&fishbones).Error + return fishbones, err +} + +func (r *deviceDetailsRepo) GetTowersByDeviceID(deviceID uuid.UUID) ([]entity.Tower, error) { + var towers []entity.Tower + err := r.db.Where("dev_id = ?", deviceID).Find(&towers).Error + return towers, err +} + + + +func (r *deviceDetailsRepo) GetPortUsageByDevice(deviceID uuid.UUID) (portUsed, portAvailable int, err error) { + var devicePort entity.DevicePort + err = r.db.Where("device_id = ?", deviceID).First(&devicePort).Error + if err != nil { + return 0, 0, err + } + return devicePort.PortUsed, devicePort.PortAvailable, nil +} + +func (r *deviceDetailsRepo) Delete(id uuid.UUID) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Delete device port first + if err := tx.Where("device_id = ?", id).Delete(&entity.DevicePort{}).Error; err != nil { + return err + } + + // Delete device + return tx.Delete(&entity.Device{}, id).Error + }) +} + +func (r *deviceDetailsRepo) AssignCustomerToPort(deviceID uuid.UUID, customerName string, portNumber *int) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Lock both device and device_port records + var device entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", deviceID).First(&device).Error; err != nil { + return err + } + + // Only ODP devices can have customers assigned + if device.DeviceType != "ODP" { + return fmt.Errorf("customers can only be assigned to ODP devices") + } + + var devicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return fmt.Errorf("device port record not found: %w", err) + } + + // Initialize port assignments if empty + if len(devicePort.PortAssignments) == 0 { + devicePort.PortAssignments = make(entity.PortAssignments, device.PortAmount) + for i := 0; i < device.PortAmount; i++ { + devicePort.PortAssignments[i] = entity.PortAssignment{ + PortNumber: i + 1, + CustomerName: nil, + Status: entity.PortStatusOff, // Default status + Bandwidth: nil, + } + } + } + + // Determine port number to use + var targetPortNumber int + if portNumber != nil { + targetPortNumber = *portNumber + if targetPortNumber < 1 || targetPortNumber > device.PortAmount { + return fmt.Errorf("port number must be between 1 and %d", device.PortAmount) + } + } else { + // Auto-assign to first available port + targetPortNumber = -1 + for i, assignment := range devicePort.PortAssignments { + if assignment.CustomerName == nil || *assignment.CustomerName == "" { + targetPortNumber = i + 1 + break + } + } + if targetPortNumber == -1 { + return fmt.Errorf("no available ports for customer assignment") + } + } + + // Check if the specified port is already occupied + portIndex := targetPortNumber - 1 + if devicePort.PortAssignments[portIndex].CustomerName != nil && + *devicePort.PortAssignments[portIndex].CustomerName != "" { + return fmt.Errorf("port %d is already occupied by %s", targetPortNumber, *devicePort.PortAssignments[portIndex].CustomerName) + } + + // Check if customer is already assigned to another port + for i, assignment := range devicePort.PortAssignments { + if assignment.CustomerName != nil && *assignment.CustomerName == customerName { + return fmt.Errorf("customer %s is already assigned to port %d", customerName, i+1) + } + } + + // Assign customer to port + devicePort.PortAssignments[portIndex].CustomerName = &customerName + devicePort.PortAssignments[portIndex].Status = entity.PortStatusOn // Default to "on" when assigning + + // Update both PortAssignments and CustomerNames for backward compatibility + r.updateDevicePortCounters(&devicePort) + devicePort.UpdatedAt = time.Now() + + return tx.Save(&devicePort).Error + }) +} + +func (r *deviceDetailsRepo) RemoveCustomerFromPort(deviceID uuid.UUID, customerName string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var devicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return fmt.Errorf("device port record not found: %w", err) + } + + // Find and remove customer + newCustomerNames := make([]string, 0) + found := false + for _, existing := range devicePort.CustomerNames { + if existing != customerName { + newCustomerNames = append(newCustomerNames, existing) + } else { + found = true + } + } + + if !found { + return fmt.Errorf("customer %s is not assigned to this device", customerName) + } + + devicePort.CustomerNames = newCustomerNames + devicePort.CustomerCount = len(devicePort.CustomerNames) + devicePort.PortAvailable = devicePort.PortAvailable + 1 + devicePort.UpdatedAt = time.Now() + + return tx.Save(&devicePort).Error + }) +} + +func (r *deviceDetailsRepo) UpdateDevicePortUsage(deviceID uuid.UUID) error { + // This method is required by the DeviceDetailsRepo interface + return r.db.Transaction(func(tx *gorm.DB) error { + var devicePort entity.DevicePort + if err := tx.Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return fmt.Errorf("device port record not found: %w", err) + } + + // Update based on port assignments + r.updateDevicePortCounters(&devicePort) + devicePort.PortUsed = devicePort.CustomerCount + devicePort.UpdatedAt = time.Now() + + return tx.Save(&devicePort).Error + }) +} + +func (r *deviceDetailsRepo) updateDevicePortCounters(devicePort *entity.DevicePort) { + // Get device port amount (need to load device if not loaded) + var device entity.Device + if devicePort.Device.ID == uuid.Nil { + r.db.Where("id = ?", devicePort.DeviceID).First(&device) + devicePort.Device = device + } else { + device = devicePort.Device + } + + // Count actual customers and find highest used port + customerCount := 0 + customerNames := make([]string, 0) + highestCustomerPort := 0 + + for _, assignment := range devicePort.PortAssignments { + if assignment.CustomerName != nil && *assignment.CustomerName != "" { + customerCount++ + customerNames = append(customerNames, *assignment.CustomerName) // Just customer names, not with port numbers + if assignment.PortNumber > highestCustomerPort { + highestCustomerPort = assignment.PortNumber + } + } + } + + // Set port_used to the highest port number that has a customer + // This ensures we don't have gaps in port usage + if customerCount == 0 { + devicePort.PortUsed = 0 + } else { + devicePort.PortUsed = highestCustomerPort + } + + // Calculate port_available + devicePort.PortAvailable = device.PortAmount - devicePort.PortUsed + + // Update customer fields + devicePort.CustomerCount = customerCount + devicePort.CustomerNames = customerNames +} + +func (r *deviceDetailsRepo) AssignMultipleCustomersToPort(deviceID uuid.UUID, assignments []req.AssignMultipleCustomersDTO) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Lock both device and device_port records + var device entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", deviceID).First(&device).Error; err != nil { + return err + } + + // Only ODP devices can have customers assigned + if device.DeviceType != "ODP" { + return fmt.Errorf("customers can only be assigned to ODP devices") + } + + var devicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return fmt.Errorf("device port record not found: %w", err) + } + + // Initialize port assignments if empty + if len(devicePort.PortAssignments) == 0 { + devicePort.PortAssignments = make(entity.PortAssignments, device.PortAmount) + for i := 0; i < device.PortAmount; i++ { + devicePort.PortAssignments[i] = entity.PortAssignment{ + PortNumber: i + 1, + CustomerName: nil, + } + } + } + + // Check if there are enough available ports + availablePorts := 0 + for _, assignment := range devicePort.PortAssignments { + if assignment.CustomerName == nil || *assignment.CustomerName == "" { + availablePorts++ + } + } + + portsNeeded := len(assignments) + if portsNeeded > availablePorts { + return fmt.Errorf("not enough available ports: need %d, available %d", portsNeeded, availablePorts) + } + + // Process each assignment + for _, assignment := range assignments { + var targetPortNumber int + + if assignment.PortNumber != nil { + targetPortNumber = *assignment.PortNumber + if targetPortNumber < 1 || targetPortNumber > device.PortAmount { + return fmt.Errorf("port number %d must be between 1 and %d", targetPortNumber, device.PortAmount) + } + } else { + // Auto-assign to first available port + targetPortNumber = -1 + for i, portAssignment := range devicePort.PortAssignments { + if portAssignment.CustomerName == nil || *portAssignment.CustomerName == "" { + targetPortNumber = i + 1 + break + } + } + if targetPortNumber == -1 { + return fmt.Errorf("no available ports for customer assignment") + } + } + + // Check if the specified port is already occupied + portIndex := targetPortNumber - 1 + if devicePort.PortAssignments[portIndex].CustomerName != nil && + *devicePort.PortAssignments[portIndex].CustomerName != "" { + return fmt.Errorf("port %d is already occupied by %s", targetPortNumber, *devicePort.PortAssignments[portIndex].CustomerName) + } + + // Check if customer is already assigned to another port + for i, portAssignment := range devicePort.PortAssignments { + if portAssignment.CustomerName != nil && *portAssignment.CustomerName == assignment.CustomerName { + return fmt.Errorf("customer %s is already assigned to port %d", assignment.CustomerName, i+1) + } + } + + // Assign customer to port + devicePort.PortAssignments[portIndex].CustomerName = &assignment.CustomerName + } + + // Update counters and backward compatibility fields + r.updateDevicePortCounters(&devicePort) + devicePort.UpdatedAt = time.Now() + + return tx.Save(&devicePort).Error + }) +} + +func (r *deviceDetailsRepo) RecalculatePortUsageAfterBulkUpdate(deviceID uuid.UUID) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var device entity.Device + if err := tx.Where("id = ?", deviceID).First(&device).Error; err != nil { + return err + } + + var devicePort entity.DevicePort + if err := tx.Where("device_id = ?", deviceID).First(&devicePort).Error; err != nil { + return err + } + + // Find the highest port number with a customer + highestUsedPort := 0 + customerCount := 0 + + for _, assignment := range devicePort.PortAssignments { + if assignment.CustomerName != nil && *assignment.CustomerName != "" { + customerCount++ + if assignment.PortNumber > highestUsedPort { + highestUsedPort = assignment.PortNumber + } + } + } + + // Update port usage + devicePort.PortUsed = highestUsedPort + devicePort.PortAvailable = device.PortAmount - devicePort.PortUsed + + // Update other counters + r.updateDevicePortCounters(&devicePort) + devicePort.UpdatedAt = time.Now() + + return tx.Save(&devicePort).Error + }) +} \ No newline at end of file diff --git a/repository/device_inspection.go b/repository/device_inspection.go new file mode 100644 index 0000000..95773a5 --- /dev/null +++ b/repository/device_inspection.go @@ -0,0 +1,155 @@ +package repository + +import ( + "users_management/m/model/entity" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type DeviceInspectionRepo interface { + Create(inspection entity.DeviceInspection) error + GetAll() ([]entity.DeviceInspection, error) + GetByID(id uuid.UUID) (entity.DeviceInspection, error) + GetByUserID(userID uuid.UUID, limit, offset int) ([]entity.DeviceInspection, error) + Update(id uuid.UUID, updates map[string]interface{}) error + CheckDeviceExists(deviceID uuid.UUID) (bool, error) + CheckBackboneExists(backboneID uuid.UUID) (bool, error) + CheckFishboneExists(fishboneID uuid.UUID) (bool, error) + CheckTowerExists(towerID uuid.UUID) (bool, error) + GetDeviceByID(deviceID uuid.UUID) (entity.Device, error) + CountCablesByDevice(deviceID uuid.UUID, deviceType string) (int, error) + CountByUserID(userID uuid.UUID) (int64, error) + CountAll() (int64, error) + UpdateDeviceStatus(deviceID uuid.UUID, status string) error + GetDevicePortByDeviceID(deviceID uuid.UUID) (entity.DevicePort, error) + UpdateDevicePort(deviceID uuid.UUID, updates map[string]interface{}) error + CheckOwnership(inspectionID, userID uuid.UUID) (bool, error) // Add this line +} + +type deviceInspectionRepo struct { + db *gorm.DB +} + +func NewDeviceInspectionRepo(db *gorm.DB) DeviceInspectionRepo { + return &deviceInspectionRepo{ + db: db, + } +} + +func (r *deviceInspectionRepo) CheckOwnership(inspectionID, userID uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.DeviceInspection{}). + Where("id = ? AND user_id = ?", inspectionID, userID). + Count(&count).Error + return count > 0, err +} + +func (r *deviceInspectionRepo) Create(inspection entity.DeviceInspection) error { + return r.db.Create(&inspection).Error +} + +func (r *deviceInspectionRepo) GetAll() ([]entity.DeviceInspection, error) { + var inspections []entity.DeviceInspection + err := r.db.Preload("Device").Preload("User").Preload("Backbone"). + Preload("Fishbone").Preload("Tower").Find(&inspections).Error + return inspections, err +} + +func (r *deviceInspectionRepo) GetByID(id uuid.UUID) (entity.DeviceInspection, error) { + var inspection entity.DeviceInspection + err := r.db.Preload("Device").Preload("User").Preload("Backbone"). + Preload("Fishbone").Preload("Tower").Where("id = ?", id).First(&inspection).Error + return inspection, err +} + +func (r *deviceInspectionRepo) GetByUserID(userID uuid.UUID, limit, offset int) ([]entity.DeviceInspection, error) { + var inspections []entity.DeviceInspection + err := r.db.Preload("Device").Preload("User").Preload("Backbone"). + Preload("Fishbone").Preload("Tower"). + Where("user_id = ?", userID). + Order("created_at DESC"). + Limit(limit).Offset(offset). + Find(&inspections).Error + return inspections, err +} + +func (r *deviceInspectionRepo) Update(id uuid.UUID, updates map[string]interface{}) error { + return r.db.Model(&entity.DeviceInspection{}).Where("id = ?", id).Updates(updates).Error +} + +func (r *deviceInspectionRepo) CheckDeviceExists(deviceID uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.Device{}).Where("id = ?", deviceID).Count(&count).Error + return count > 0, err +} + +func (r *deviceInspectionRepo) CheckBackboneExists(backboneID uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.Backbone{}).Where("id = ?", backboneID).Count(&count).Error + return count > 0, err +} + +func (r *deviceInspectionRepo) CheckFishboneExists(fishboneID uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.Fishbone{}).Where("id = ?", fishboneID).Count(&count).Error + return count > 0, err +} + +func (r *deviceInspectionRepo) CheckTowerExists(towerID uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.Tower{}).Where("id = ?", towerID).Count(&count).Error + return count > 0, err +} + +func (r *deviceInspectionRepo) GetDeviceByID(deviceID uuid.UUID) (entity.Device, error) { + var device entity.Device + err := r.db.Where("id = ?", deviceID).First(&device).Error + return device, err +} + +func (r *deviceInspectionRepo) CountCablesByDevice(deviceID uuid.UUID, deviceType string) (int, error) { + var count int64 + + if deviceType == "OTB" { + // Count backbones using this device + err := r.db.Model(&entity.Backbone{}). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Count(&count).Error + return int(count), err + } else if deviceType == "ODP" { + // Sum core amounts of fishbones using this device + var totalCores int64 + err := r.db.Model(&entity.Fishbone{}). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Select("COALESCE(SUM(core_amount), 0)").Scan(&totalCores).Error + return int(totalCores), err + } + + return 0, nil +} + +func (r *deviceInspectionRepo) CountByUserID(userID uuid.UUID) (int64, error) { + var count int64 + err := r.db.Model(&entity.DeviceInspection{}).Where("user_id = ?", userID).Count(&count).Error + return count, err +} + +func (r *deviceInspectionRepo) CountAll() (int64, error) { + var count int64 + err := r.db.Model(&entity.DeviceInspection{}).Count(&count).Error + return count, err +} + +func (r *deviceInspectionRepo) UpdateDeviceStatus(deviceID uuid.UUID, status string) error { + return r.db.Model(&entity.Device{}).Where("id = ?", deviceID).Update("status", status).Error +} + +func (r *deviceInspectionRepo) GetDevicePortByDeviceID(deviceID uuid.UUID) (entity.DevicePort, error) { + var devicePort entity.DevicePort + err := r.db.Where("device_id = ?", deviceID).First(&devicePort).Error + return devicePort, err +} + +func (r *deviceInspectionRepo) UpdateDevicePort(deviceID uuid.UUID, updates map[string]interface{}) error { + return r.db.Model(&entity.DevicePort{}).Where("device_id = ?", deviceID).Updates(updates).Error +} \ No newline at end of file diff --git a/repository/devices_repo.go b/repository/devices_repo.go index 716ca54..744b733 100644 --- a/repository/devices_repo.go +++ b/repository/devices_repo.go @@ -1,6 +1,8 @@ package repository import ( + "fmt" + "time" "users_management/m/model/entity" "github.com/google/uuid" @@ -13,6 +15,11 @@ type DevicesRepo interface { Update(id uuid.UUID,updates map[string]interface{}) error 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 + SyncTowerLocationWithDevice(deviceID uuid.UUID) error + ValidateTowerExists(towerID uuid.UUID) (bool, error) } type devicesRepo struct { @@ -25,14 +32,130 @@ func NewDevicesRepo(db *gorm.DB) DevicesRepo { } } -func (r *devicesRepo) Post(device entity.Device) error { - err := r.db.Create(&device).Error - if err != nil { - return err - } - return nil +func (r *devicesRepo) SyncTowerLocationWithDevice(deviceID uuid.UUID) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Get device details + var device entity.Device + if err := tx.Where("id = ?", deviceID).First(&device).Error; err != nil { + return fmt.Errorf("device not found: %w", err) + } + + // Update tower location if device has a tower assigned + if device.TowerID != nil { + if err := tx.Model(&entity.Tower{}). + Where("id = ?", *device.TowerID). + Updates(map[string]interface{}{ + "longitude": device.Longitude, + "latitude": device.Latitude, + "updated_at": time.Now(), + }).Error; err != nil { + return fmt.Errorf("failed to sync tower location: %w", err) + } + } + + // Also update any towers that reference this device via dev_id + if err := tx.Model(&entity.Tower{}). + Where("dev_id = ?", deviceID). + Updates(map[string]interface{}{ + "longitude": device.Longitude, + "latitude": device.Latitude, + "updated_at": time.Now(), + }).Error; err != nil { + return fmt.Errorf("failed to sync related towers location: %w", err) + } + + return nil + }) } +func (r *devicesRepo) ValidateTowerExists(towerID uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.Tower{}).Where("id = ?", towerID).Count(&count).Error + return count > 0, err +} + +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 + if err := tx.Create(&device).Error; err != nil { + return err + } + + // Create the corresponding DevicePort record + customerCount := 0 + customerNames := make([]string, 0) + + // For ODP devices, initialize customer tracking + if device.DeviceType == "ODP" { + // Customer count starts at 0, will be updated when fishbones are connected + customerCount = 0 + } + + devicePort := entity.DevicePort{ + ID: uuid.New(), + DeviceID: device.ID, + PortUsed: 0, + PortAvailable: device.PortAmount, + CustomerCount: customerCount, + CustomerNames: customerNames, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := tx.Create(&devicePort).Error; err != nil { + return err + } + + // Sync tower location with device location + if device.TowerID != nil { + if err := tx.Model(&entity.Tower{}). + Where("id = ?", *device.TowerID). + Updates(map[string]interface{}{ + "longitude": device.Longitude, + "latitude": device.Latitude, + "updated_at": time.Now(), + }).Error; err != nil { + return fmt.Errorf("failed to sync tower location: %w", err) + } + } + + return nil + }) +} func (r *devicesRepo) GetAll() ([]entity.Device, error) { var devices []entity.Device err := r.db.Find(&devices).Error @@ -41,12 +164,44 @@ func (r *devicesRepo) GetAll() ([]entity.Device, error) { } return devices, nil } -func (r *devicesRepo) Update(id uuid.UUID,updates map[string]interface{}) error { - err := r.db.Model(&entity.Device{}).Where("id = ?", id).Updates(updates).Error - if err != nil { - return err - } - return nil +func (r *devicesRepo) Update(id uuid.UUID, updates map[string]interface{}) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Validate tower exists if TowerID is being updated + if towerID, exists := updates["TowerID"]; exists && towerID != nil { + towerUUID := towerID.(uuid.UUID) + var towerExists bool + var err error + if towerExists, err = r.ValidateTowerExists(towerUUID); err != nil { + return fmt.Errorf("failed to validate tower: %w", err) + } + if !towerExists { + return fmt.Errorf("tower with ID %s not found", towerUUID.String()) + } + } + + // Update device + if err := tx.Model(&entity.Device{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return err + } + + // If location is updated, sync with tower + if _, hasLng := updates["Longitude"]; hasLng { + if _, hasLat := updates["Latitude"]; hasLat { + if err := r.SyncTowerLocationWithDevice(id); err != nil { + return err + } + } + } + + // If TowerID is updated, sync location + if _, exists := updates["TowerID"]; exists { + if err := r.SyncTowerLocationWithDevice(id); err != nil { + return err + } + } + + return nil + }) } func (r *devicesRepo) GetByID(id uuid.UUID) (entity.Device, error) { @@ -57,3 +212,12 @@ func (r *devicesRepo) GetByID(id uuid.UUID) (entity.Device, error) { } return device, nil } + +func (r *devicesRepo) GetByType(deviceType string) ([]entity.Device, error) { + var devices []entity.Device + err := r.db.Where("device_type = ?", deviceType).Find(&devices).Error + if err != nil { + return devices, err + } + return devices, nil +} diff --git a/repository/fishbone_repo.go b/repository/fishbone_repo.go index 0e7f537..dd0305b 100644 --- a/repository/fishbone_repo.go +++ b/repository/fishbone_repo.go @@ -13,6 +13,17 @@ type FishboneRepo interface { Update(id uuid.UUID,updates map[string]interface{}) error GetByID(id uuid.UUID) (entity.Fishbone, error) + CountFishbone() (map[uuid.UUID]int, error) + + CountFishboneByBackboneID(backboneID uuid.UUID) (int, error) + GetAllWithRelations() ([]entity.Fishbone, error) + GetByIDWithRelations(id uuid.UUID) (entity.Fishbone, error) + CheckFishboneExists(id uuid.UUID) (bool, error) + CheckBackboneExists(id uuid.UUID) (bool, error) + CheckDeviceExists(id uuid.UUID) (bool, error) + Delete(id uuid.UUID) error + WithTransaction(fn func(*gorm.DB) error) error + GetByBackboneID(backboneID uuid.UUID) ([]entity.Fishbone, error) } type fishboneRepo struct { @@ -33,15 +44,30 @@ func (r *fishboneRepo) Post(fishbone entity.Fishbone) error { return nil } +func (r *fishboneRepo) GetByBackboneID(backboneID uuid.UUID) ([]entity.Fishbone, error) { + var fishbones []entity.Fishbone + err := r.db.Where("bb_id = ?", backboneID). + Preload("DeviceStart"). + Preload("DeviceEnd"). + Preload("Backbone"). + Find(&fishbones).Error + return fishbones, err +} + func (r *fishboneRepo) GetAll() ([]entity.Fishbone, error) { var fishbones []entity.Fishbone - err := r.db.Preload("DeviceStart").Preload("DeviceEnd").Preload("Backbone").Preload("Backbone.DeviceStart").Preload("Backbone.DeviceEnd").Find(&fishbones).Error + err := r.db.Preload("DeviceStart").Preload("DeviceEnd").Preload("Backbone").Find(&fishbones).Error if err != nil { return fishbones, err } + return fishbones, nil } +func (r *fishboneRepo) WithTransaction(fn func(*gorm.DB) error) error { + return r.db.Transaction(fn) +} + func (r *fishboneRepo) Update(id uuid.UUID,updates map[string]interface{}) error { err := r.db.Model(&entity.Fishbone{}).Where("id = ?", id).Updates(updates).Error if err != nil { @@ -57,4 +83,71 @@ func (r *fishboneRepo) GetByID(id uuid.UUID) (entity.Fishbone, error) { return fishbone, err } return fishbone, nil +} +func (r *fishboneRepo) CountFishbone() (map[uuid.UUID]int, error) { + type Result struct { + BackboneID uuid.UUID `gorm:"column:bb_id"` + Count int `gorm:"column:count"` + } + + var results []Result + + err := r.db.Model(&entity.Fishbone{}). + Select("bb_id, count(*) as count"). + Group("bb_id"). + Scan(&results).Error + + if err != nil { + return nil, err + } + + countMap := make(map[uuid.UUID]int) + for _, result := range results { + countMap[result.BackboneID] = result.Count + } + + return countMap, nil +} + +func (r *fishboneRepo) CountFishboneByBackboneID(backboneID uuid.UUID) (int, error) { + var count int64 + err := r.db.Model(&entity.Fishbone{}).Where("bb_id = ?", backboneID).Count(&count).Error + if err != nil { + return 0, err + } + return int(count), nil +} + +func (r *fishboneRepo) GetAllWithRelations() ([]entity.Fishbone, error) { + var fishbones []entity.Fishbone + err := r.db.Preload("Backbone").Preload("DeviceStart").Preload("DeviceEnd").Find(&fishbones).Error + return fishbones, err +} + +func (r *fishboneRepo) GetByIDWithRelations(id uuid.UUID) (entity.Fishbone, error) { + var fishbone entity.Fishbone + err := r.db.Preload("Backbone").Preload("DeviceStart").Preload("DeviceEnd").Where("id = ?", id).First(&fishbone).Error + return fishbone, err +} + +func (r *fishboneRepo) CheckFishboneExists(id uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.Fishbone{}).Where("id = ?", id).Count(&count).Error + return count > 0, err +} + +func (r *fishboneRepo) CheckBackboneExists(id uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.Backbone{}).Where("id = ?", id).Count(&count).Error + return count > 0, err +} + +func (r *fishboneRepo) CheckDeviceExists(id uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.Device{}).Where("id = ?", id).Count(&count).Error + return count > 0, err +} + +func (r *fishboneRepo) Delete(id uuid.UUID) error { + return r.db.Where("id = ?", id).Delete(&entity.Fishbone{}).Error } \ No newline at end of file diff --git a/repository/nearest_device_repo.go b/repository/nearest_device_repo.go new file mode 100644 index 0000000..75865f5 --- /dev/null +++ b/repository/nearest_device_repo.go @@ -0,0 +1,159 @@ +package repository + +import ( + "users_management/m/model/entity" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type NearestDeviceRepo interface { + GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.DeviceWithDistance, error) + GetDeviceByIDWithConnections(id uuid.UUID) (entity.Device, error) + GetBackbonesByDeviceID(deviceID uuid.UUID) ([]entity.Backbone, error) + GetFishbonesByDeviceID(deviceID uuid.UUID) ([]entity.Fishbone, error) + GetTowersByDeviceID(deviceID uuid.UUID) ([]entity.Tower, error) + CountConnectionsByDeviceID(deviceID uuid.UUID) (backboneCount, fishboneCount, towerCount int, err error) + GetNearestTowers(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.TowerWithDistance, error) + GetTowerByIDWithConnections(id uuid.UUID) (entity.Tower, error) + +} + +type nearestDeviceRepo struct { + db *gorm.DB +} + +func NewNearestDeviceRepo(db *gorm.DB) NearestDeviceRepo { + return &nearestDeviceRepo{ + db: db, + } +} + +func (r *nearestDeviceRepo) GetNearestTowers(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.TowerWithDistance, error) { + var towers []entity.TowerWithDistance + + // Build the subquery first - join with devices to get location filters + subQuery := r.db.Table("towers"). + Select(`towers.id, towers.tower_code, towers.longitude, towers.latitude, + towers.image_url, towers.external_tower, towers.dev_id, towers.created_at, towers.updated_at, + devices.device_code, devices.province, devices.city, devices.district, + (6371 * acos(cos(radians(?)) * cos(radians(towers.latitude)) * cos(radians(towers.longitude) - radians(?)) + sin(radians(?)) * sin(radians(towers.latitude)))) AS distance`, + latitude, longitude, latitude). + Joins("LEFT JOIN devices ON towers.dev_id = devices.id") + + // Apply location filters to subquery + if province != nil && *province != "" { + subQuery = subQuery.Where("devices.province = ?", *province) + } + if city != nil && *city != "" { + subQuery = subQuery.Where("devices.city = ?", *city) + } + if district != nil && *district != "" { + subQuery = subQuery.Where("devices.district = ?", *district) + } + + // Use the subquery in the main query + err := r.db.Table("(?) as towers_with_distance", subQuery). + Where("distance <= ?", radius). + Order("distance ASC"). + Limit(limit). + Scan(&towers).Error + + return towers, err +} + +func (r *nearestDeviceRepo) GetTowerByIDWithConnections(id uuid.UUID) (entity.Tower, error) { + var tower entity.Tower + err := r.db.Preload("Device").Where("id = ?", id).First(&tower).Error + return tower, err +} + +func (r *nearestDeviceRepo) GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.DeviceWithDistance, error) { + var devices []entity.DeviceWithDistance + + // Build the subquery first + subQuery := r.db.Table("devices"). + Select(`id, device_code, device_type, longitude, latitude, port_amount, status, + region, province, city, district, image_url, created_at, updated_at, + (6371 * acos(cos(radians(?)) * cos(radians(latitude)) * cos(radians(longitude) - radians(?)) + sin(radians(?)) * sin(radians(latitude)))) AS distance`, + latitude, longitude, latitude) + + // Apply location filters to subquery + if province != nil && *province != "" { + subQuery = subQuery.Where("province = ?", *province) + } + if city != nil && *city != "" { + subQuery = subQuery.Where("city = ?", *city) + } + if district != nil && *district != "" { + subQuery = subQuery.Where("district = ?", *district) + } + + // Use the subquery in the main query + err := r.db.Table("(?) as devices_with_distance", subQuery). + Where("distance <= ?", radius). + Order("distance ASC"). + Limit(limit). + Scan(&devices).Error + + return devices, err +} + +func (r *nearestDeviceRepo) GetDeviceByIDWithConnections(id uuid.UUID) (entity.Device, error) { + var device entity.Device + err := r.db.Where("id = ?", id).First(&device).Error + return device, err +} + +func (r *nearestDeviceRepo) GetBackbonesByDeviceID(deviceID uuid.UUID) ([]entity.Backbone, error) { + var backbones []entity.Backbone + err := r.db.Preload("DeviceStart").Preload("DeviceEnd"). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Find(&backbones).Error + return backbones, err +} + +func (r *nearestDeviceRepo) GetFishbonesByDeviceID(deviceID uuid.UUID) ([]entity.Fishbone, error) { + var fishbones []entity.Fishbone + err := r.db.Preload("Backbone").Preload("DeviceStart").Preload("DeviceEnd"). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Find(&fishbones).Error + return fishbones, err +} + +func (r *nearestDeviceRepo) GetTowersByDeviceID(deviceID uuid.UUID) ([]entity.Tower, error) { + var towers []entity.Tower + err := r.db.Preload("Device"). + Where("dev_id = ?", deviceID). + Find(&towers).Error + return towers, err +} + +func (r *nearestDeviceRepo) CountConnectionsByDeviceID(deviceID uuid.UUID) (backboneCount, fishboneCount, towerCount int, err error) { + var backboneCountInt64, fishboneCountInt64, towerCountInt64 int64 + + // Count backbones + err = r.db.Model(&entity.Backbone{}). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Count(&backboneCountInt64).Error + if err != nil { + return 0, 0, 0, err + } + + // Count fishbones + err = r.db.Model(&entity.Fishbone{}). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Count(&fishboneCountInt64).Error + if err != nil { + return 0, 0, 0, err + } + + // Count towers + err = r.db.Model(&entity.Tower{}). + Where("dev_id = ?", deviceID). + Count(&towerCountInt64).Error + if err != nil { + return 0, 0, 0, err + } + + return int(backboneCountInt64), int(fishboneCountInt64), int(towerCountInt64), nil +} \ No newline at end of file diff --git a/repository/olt_repo.go b/repository/olt_repo.go new file mode 100644 index 0000000..fdcddc1 --- /dev/null +++ b/repository/olt_repo.go @@ -0,0 +1,127 @@ +package repository + +import ( + "fmt" + "users_management/m/model/entity" + "users_management/m/model/dto/req" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type OLTRepo interface { + Create(olt entity.OLT) error + GetAll() ([]entity.OLT, error) + GetByID(id uuid.UUID) (entity.OLT, error) + GetByIDWithDevices(id uuid.UUID) (entity.OLT, error) + Update(id uuid.UUID, updateDTO req.UpdateOLTDTO) error + Delete(id uuid.UUID) error + AssignDeviceToOLT(oltID, deviceID uuid.UUID) error + UnassignDeviceFromOLT(deviceID uuid.UUID) error + GetByName(name string) (entity.OLT, error) + GetDevicesByOLTID(oltID uuid.UUID) ([]entity.Device, error) +} + +type oltRepo struct { + db *gorm.DB +} + +func NewOLTRepo(db *gorm.DB) OLTRepo { + return &oltRepo{db: db} +} + +func (r *oltRepo) Create(olt entity.OLT) error { + return r.db.Create(&olt).Error +} + +func (r *oltRepo) GetAll() ([]entity.OLT, error) { + var olts []entity.OLT + err := r.db.Order("created_at DESC").Find(&olts).Error + return olts, err +} + +func (r *oltRepo) GetByID(id uuid.UUID) (entity.OLT, error) { + var olt entity.OLT + err := r.db.Where("id = ?", id).First(&olt).Error + return olt, err +} + +func (r *oltRepo) GetByIDWithDevices(id uuid.UUID) (entity.OLT, error) { + var olt entity.OLT + err := r.db.Preload("Devices"). + Preload("Devices.DevicePort"). + Where("id = ?", id). + First(&olt).Error + return olt, err +} + +func (r *oltRepo) Update(id uuid.UUID, updateDTO req.UpdateOLTDTO) error { + updates := make(map[string]interface{}) + + if updateDTO.OLTName != nil { + updates["olt_name"] = *updateDTO.OLTName + } + + if len(updates) == 0 { + return fmt.Errorf("no fields to update") + } + + return r.db.Model(&entity.OLT{}).Where("id = ?", id).Updates(updates).Error +} + +func (r *oltRepo) Delete(id uuid.UUID) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // First, unassign all devices from this OLT + if err := tx.Model(&entity.Device{}). + Where("olt_id = ?", id). + Update("olt_id", nil).Error; err != nil { + return err + } + + // Then delete the OLT + return tx.Where("id = ?", id).Delete(&entity.OLT{}).Error + }) +} + +func (r *oltRepo) AssignDeviceToOLT(oltID, deviceID uuid.UUID) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Check if OLT exists + var olt entity.OLT + if err := tx.Where("id = ?", oltID).First(&olt).Error; err != nil { + return fmt.Errorf("OLT not found: %w", err) + } + + // Check if device exists and is ODP type + var device entity.Device + if err := tx.Where("id = ?", deviceID).First(&device).Error; err != nil { + return fmt.Errorf("device not found: %w", err) + } + + if device.DeviceType != entity.ODP { + return fmt.Errorf("only ODP devices can be assigned to OLT") + } + + // Assign device to OLT + return tx.Model(&device).Update("olt_id", oltID).Error + }) +} + +func (r *oltRepo) UnassignDeviceFromOLT(deviceID uuid.UUID) error { + return r.db.Model(&entity.Device{}). + Where("id = ?", deviceID). + Update("olt_id", nil).Error +} + +func (r *oltRepo) GetByName(name string) (entity.OLT, error) { + var olt entity.OLT + err := r.db.Where("olt_name = ?", name).First(&olt).Error + return olt, err +} + +func (r *oltRepo) GetDevicesByOLTID(oltID uuid.UUID) ([]entity.Device, error) { + var devices []entity.Device + err := r.db.Preload("DevicePort"). + Where("olt_id = ? AND device_type = ?", oltID, entity.ODP). + Find(&devices).Error + return devices, err +} \ No newline at end of file diff --git a/repository/tower_repo.go b/repository/tower_repo.go index 6a65267..891a672 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" @@ -12,6 +14,9 @@ type TowerRepo interface { GetAll() ([]entity.Tower, error) 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 + GetDeviceLocation(deviceID uuid.UUID) (longitude, latitude float64, err error) } type towerRepo struct { @@ -24,6 +29,15 @@ func NewTowerRepo(db *gorm.DB) TowerRepo { } } +func (r *towerRepo) GetDeviceLocation(deviceID uuid.UUID) (longitude, latitude float64, err error) { + var device entity.Device + err = r.db.Select("longitude, latitude").Where("id = ?", deviceID).First(&device).Error + if err != nil { + return 0, 0, fmt.Errorf("device not found: %w", err) + } + return device.Longitude, device.Latitude, nil +} + func (r *towerRepo) Post(tower entity.Tower) error { err := r.db.Create(&tower).Error if err != nil { @@ -34,13 +48,19 @@ func (r *towerRepo) Post(tower entity.Tower) error { func (r *towerRepo) GetAll() ([]entity.Tower, error) { var towers []entity.Tower - err := r.db.Preload("Device").Find(&towers).Error - if err != nil { - return towers, err + // Chain debug methods + result := r.db.Preload("Device").Find(&towers) + if result.Error != nil { + return nil, result.Error } return towers, nil } +func (r *towerRepo) CheckDeviceExists(deviceID uuid.UUID) (bool, error) { + var count int64 + err := r.db.Model(&entity.Device{}).Where("id = ?", deviceID).Count(&count).Error + return count > 0, err +} func (r *towerRepo) Update(id uuid.UUID,updates map[string]interface{}) error { err := r.db.Model(&entity.Tower{}).Where("id = ?", id).Updates(updates).Error if err != nil { @@ -56,4 +76,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/repository/users_repo.go b/repository/users_repo.go index cd2ca6d..3ee1aed 100644 --- a/repository/users_repo.go +++ b/repository/users_repo.go @@ -3,48 +3,127 @@ package repository import ( "users_management/m/model/entity" + "github.com/google/uuid" "gorm.io/gorm" ) type UsersRepo interface { - Post(user entity.User) error - GetRoleByDepartment(departmentName string) (entity.Role, error) - GetUserByUsername(username string) (entity.User, error) + Post(user entity.User) error + GetRoleByDepartment(departmentName string) (entity.Role, error) + GetUserByUsername(username string) (entity.User, error) + GetUserByNomorInduk(nomorInduk string) (entity.User, error) + CreateUserFromExternal(user entity.User) error + UpdateUserRole(nomorInduk string, roleID uuid.UUID) error + GetAllUsers() ([]entity.User, error) + RegisterUser(user entity.User) error + GetPendingUsers() ([]entity.User, error) + UpdateUserStatus(userID uuid.UUID, status entity.UserStatus) error + GetUserByID(userID uuid.UUID) (entity.User, error) + GetUserByUsernameWithStatus(username string) (entity.User, error) + CreateSuperAdminBypass(user entity.User) error + UpdateUserRoleByID(userID uuid.UUID, roleID uuid.UUID) error + UpdateUserRoleByUsername(username string, roleID uuid.UUID) error + GetUserByName(name string) (entity.User, error) } type usersRepo struct { - db *gorm.DB + db *gorm.DB } func NewUsersRepo(db *gorm.DB) UsersRepo { - return &usersRepo{ - db: db, - } + return &usersRepo{ + db: db, + } +} + +func (r *usersRepo) GetUserByName(name string) (entity.User, error) { + var user entity.User + // Use LOWER() function for case-insensitive comparison + err := r.db.Where("LOWER(name) = LOWER(?)", name).Preload("Role").First(&user).Error + return user, err +} + +func (r *usersRepo) UpdateUserRoleByID(userID uuid.UUID, roleID uuid.UUID) error { + return r.db.Model(&entity.User{}).Where("id = ?", userID).Update("role_id", roleID).Error +} + +func (r *usersRepo) UpdateUserRoleByUsername(username string, roleID uuid.UUID) error { + return r.db.Model(&entity.User{}).Where("username = ?", username).Update("role_id", roleID).Error +} + +func (r *usersRepo) CreateSuperAdminBypass(user entity.User) error { + return r.db.Create(&user).Error +} + +func (r *usersRepo) RegisterUser(user entity.User) error { + return r.db.Create(&user).Error +} + +func (r *usersRepo) GetPendingUsers() ([]entity.User, error) { + var users []entity.User + err := r.db.Where("status = ?", entity.UserStatusPending).Preload("Role").Find(&users).Error + return users, err +} + +func (r *usersRepo) UpdateUserStatus(userID uuid.UUID, status entity.UserStatus) error { + return r.db.Model(&entity.User{}).Where("id = ?", userID).Update("status", status).Error +} + +func (r *usersRepo) GetUserByID(userID uuid.UUID) (entity.User, error) { + var user entity.User + err := r.db.Where("id = ?", userID).Preload("Role").First(&user).Error + return user, err +} + +func (r *usersRepo) GetUserByUsernameWithStatus(username string) (entity.User, error) { + var user entity.User + err := r.db.Where("username = ?", username).Preload("Role").First(&user).Error + return user, err } func (r *usersRepo) Post(user entity.User) error { - err := r.db.Create(&user).Error - if err != nil { - return err - } - return nil + err := r.db.Create(&user).Error + if err != nil { + return err + } + return nil } func (r *usersRepo) GetRoleByDepartment(departmentName string) (entity.Role, error) { - var role entity.Role - err := r.db.Where("name = ?", departmentName).First(&role).Error - if err != nil { - return role, err - } - return role, nil + var role entity.Role + err := r.db.Where("name = ?", departmentName).First(&role).Error + if err != nil { + return role, err + } + return role, nil } func (r *usersRepo) GetUserByUsername(username string) (entity.User, error) { - var users entity.User - err := r.db.Where("username = ?", username).First(&users).Error - if err != nil { - return users, err - } - return users, nil + var users entity.User + err := r.db.Where("username = ?", username).Preload("Role").First(&users).Error + if err != nil { + return users, err + } + return users, nil } +// New methods for RBAC +func (r *usersRepo) GetUserByNomorInduk(nomorInduk string) (entity.User, error) { + var user entity.User + err := r.db.Where("nomor_induk = ?", nomorInduk).Preload("Role").First(&user).Error + return user, err +} + +func (r *usersRepo) CreateUserFromExternal(user entity.User) error { + return r.db.Create(&user).Error +} + +func (r *usersRepo) UpdateUserRole(nomorInduk string, roleID uuid.UUID) error { + return r.db.Model(&entity.User{}).Where("nomor_induk = ?", nomorInduk).Update("role_id", roleID).Error +} + +func (r *usersRepo) GetAllUsers() ([]entity.User, error) { + var users []entity.User + err := r.db.Preload("Role").Find(&users).Error + return users, err +} \ No newline at end of file diff --git a/uploads/towers/ae4227e6-8492-415f-af47-95e7464de18f_1749704734.jpg b/uploads/towers/ae4227e6-8492-415f-af47-95e7464de18f_1749704734.jpg new file mode 100644 index 0000000..ed5ce6a Binary files /dev/null and b/uploads/towers/ae4227e6-8492-415f-af47-95e7464de18f_1749704734.jpg differ diff --git a/uploads/towers/c990cf49-f6ea-4d81-97a7-d3748afec6c4_1749711025.jpeg b/uploads/towers/c990cf49-f6ea-4d81-97a7-d3748afec6c4_1749711025.jpeg new file mode 100644 index 0000000..d919e15 Binary files /dev/null and b/uploads/towers/c990cf49-f6ea-4d81-97a7-d3748afec6c4_1749711025.jpeg differ diff --git a/uploads/towers/f55d71d2-753f-4219-8650-a5f48ca09b0f_1749707561.png b/uploads/towers/f55d71d2-753f-4219-8650-a5f48ca09b0f_1749707561.png new file mode 100644 index 0000000..b5173f9 Binary files /dev/null and b/uploads/towers/f55d71d2-753f-4219-8650-a5f48ca09b0f_1749707561.png differ diff --git a/usecase/activity_logs_usecase.go b/usecase/activity_logs_usecase.go new file mode 100644 index 0000000..6761e7a --- /dev/null +++ b/usecase/activity_logs_usecase.go @@ -0,0 +1,99 @@ +package usecase + +import ( + "encoding/json" + "users_management/m/model/entity" + "users_management/m/repository" + + "github.com/google/uuid" +) + +type ActivityLogUseCase interface { + LogActivity(userID uuid.UUID, action, resource string, resourceID *string, oldData, newData interface{}, ipAddress, userAgent, details string) error + GetUserLogs(userID uuid.UUID, page, limit int) ([]entity.ActivityLog, int64, error) + GetAllLogs(page, limit int) ([]entity.ActivityLog, int64, error) + GetTeknisinLogs(page, limit int) ([]entity.ActivityLog, int64, error) +} + +type activityLogUseCase struct { + activityLogRepo repository.ActivityLogRepo +} + +func NewActivityLogUseCase(activityLogRepo repository.ActivityLogRepo) ActivityLogUseCase { + return &activityLogUseCase{ + activityLogRepo: activityLogRepo, + } +} + +func (uc *activityLogUseCase) LogActivity(userID uuid.UUID, action, resource string, resourceID *string, oldData, newData interface{}, ipAddress, userAgent, details string) error { + var oldDataJSON, newDataJSON *string + + if oldData != nil { + oldBytes, err := json.Marshal(oldData) + if err == nil { + oldStr := string(oldBytes) + oldDataJSON = &oldStr + } + } + + if newData != nil { + newBytes, err := json.Marshal(newData) + if err == nil { + newStr := string(newBytes) + newDataJSON = &newStr + } + } + + var detailsPtr *string + if details != "" { + detailsPtr = &details + } + + log := entity.ActivityLog{ + ID: uuid.New(), + UserID: userID, + Action: action, + Resource: resource, + ResourceID: resourceID, + OldData: oldDataJSON, + NewData: newDataJSON, + IPAddress: ipAddress, + UserAgent: userAgent, + Details: detailsPtr, + } + + return uc.activityLogRepo.Create(log) +} + +func (uc *activityLogUseCase) GetUserLogs(userID uuid.UUID, page, limit int) ([]entity.ActivityLog, int64, error) { + offset := (page - 1) * limit + logs, err := uc.activityLogRepo.GetByUserID(userID, limit, offset) + if err != nil { + return nil, 0, err + } + + total, err := uc.activityLogRepo.CountByUserID(userID) + return logs, total, err +} + +func (uc *activityLogUseCase) GetAllLogs(page, limit int) ([]entity.ActivityLog, int64, error) { + offset := (page - 1) * limit + logs, err := uc.activityLogRepo.GetAllForAdmins(limit, offset) + if err != nil { + return nil, 0, err + } + + total, err := uc.activityLogRepo.CountAll() + return logs, total, err +} + +func (uc *activityLogUseCase) GetTeknisinLogs(page, limit int) ([]entity.ActivityLog, int64, error) { + offset := (page - 1) * limit + logs, err := uc.activityLogRepo.GetByUserRole("Teknisi", limit, offset) + if err != nil { + return nil, 0, err + } + + total, err := uc.activityLogRepo.CountByUserRole("Teknisi") + return logs, total, err +} \ No newline at end of file diff --git a/usecase/auth_usecase.go b/usecase/auth_usecase.go index 2d84e7e..5d4da80 100644 --- a/usecase/auth_usecase.go +++ b/usecase/auth_usecase.go @@ -1,181 +1,95 @@ package usecase import ( - "bytes" - "encoding/json" - "errors" - "io/ioutil" - "net/http" - "time" - "users_management/m/config" - "users_management/m/model/dto" - "users_management/m/model/dto/res" - "users_management/m/model/entity" - "users_management/m/repository" - "users_management/m/utils" + "errors" + "users_management/m/config" + "users_management/m/model/dto" + "users_management/m/model/entity" + "users_management/m/repository" + "users_management/m/utils" - "github.com/go-playground/validator/v10" - "github.com/google/uuid" - "gorm.io/gorm" + "github.com/go-playground/validator/v10" ) type AuthUsecase interface { - Login(login dto.UserLoginDTO) (string,string, string ,error) - Logout(token string) error + Login(login dto.UserLoginDTO) (string, string, string, error) + Logout(token string) error + ValidateToken(token string) (string, error) } type authUsecase struct { userRepo repository.UsersRepo - validate *validator.Validate cfg *config.Config + validate *validator.Validate } func NewAuthUsecase(userRepo repository.UsersRepo, cfg *config.Config) AuthUsecase { return &authUsecase{ userRepo: userRepo, - validate: validator.New(), cfg: cfg, + validate: validator.New(), } } func (u *authUsecase) Login(login dto.UserLoginDTO) (string, string, string, error) { - err := u.validate.Struct(login) + // Validate input + if err := u.validate.Struct(login); err != nil { + return "", "", "", err + } + + // Get user from database (only approved users can login) + user, err := u.userRepo.GetUserByUsername(login.Username) if err != nil { - return "","","", err + return "", "", "", errors.New("invalid username or password") } - payload, err := json.Marshal(login) - if err != nil { - return "","","", err - } - - req, err := http.NewRequest("POST", u.cfg.LoginAPI, bytes.NewBuffer(payload)) - if err != nil { - return "","","", err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return "","","", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "","","", errors.New("wrong password or username") - } - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "","","", err - } - - var authResponse res.AuthResponses - err = json.Unmarshal(body, &authResponse) - if err != nil { - return "","","", err - } - - token := authResponse.Token - - meReq, err := http.NewRequest("POST", u.cfg.AuthMeAPI, nil) - if err != nil { - return "","","", err - } - meReq.Header.Set("Authorization", "Bearer "+token) - meReq.Header.Set("Accept", "application/json") - - meResp, err := client.Do(meReq) - if err != nil { - return "","","", err - } - defer meResp.Body.Close() - - if meResp.StatusCode != http.StatusOK { - return "","","", errors.New("failed to validate token: " + meResp.Status) - } - - meBody, err := ioutil.ReadAll(meResp.Body) - if err != nil { - return "","","", err - } - - var meResponse res.AuthMeResponse - err = json.Unmarshal(meBody, &meResponse) - if err != nil { - return "","","", err - } - - departemen := meResponse.Data.Departemen - if departemen != "Teknisi" { - return "","","", errors.New("user is not a technician") - } - - role , err := u.userRepo.GetRoleByDepartment(departemen) - - if err != nil { - return "","","", err - } - - password, err := utils.HashPassword(login.Password) - if err != nil { - return "error while hasing password: ","", "", err - } - - isUserExist, err := u.userRepo.GetUserByUsername(login.Username) - - if isUserExist.ID != uuid.Nil { - // Validate the password - if !utils.CheckPasswordHash(login.Password, isUserExist.Password) { - return "","","", errors.New("incorrect password") + // Check user status + if user.Status != entity.UserStatusApproved { + switch user.Status { + case entity.UserStatusPending: + return "", "", "", errors.New("your account is pending approval from an administrator") + case entity.UserStatusRejected: + return "", "", "", errors.New("your account has been rejected. Please contact an administrator") + default: + return "", "", "", errors.New("your account is not active") } + } - return token, role.Name, isUserExist.Name, nil - }else if err != nil && err != gorm.ErrRecordNotFound { - return "ERROR WHILE SEARCHING USERNAME","", "", err - } + // Verify password + if !utils.CheckPasswordHash(login.Password, user.Password) { + return "", "", "", errors.New("invalid username or password") + } - - user := entity.User{ - ID: uuid.New(), - Name: meResponse.Data.Nama, - Username: login.Username, - Password: password, - RoleID: role.Id, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - err = u.userRepo.Post(user) - if err != nil { - return "","","", err - } + // Generate JWT token + token, err := utils.GenerateJWT(user.ID.String(), user.Username, user.Role.Name) + if err != nil { + return "", "", "", err + } return token, user.Role.Name, user.Name, nil } func (u *authUsecase) Logout(token string) error { - req, err := http.NewRequest("POST", u.cfg.LogoutAPI, nil) - if err != nil { - return err - } + // For JWT, we could implement a blacklist mechanism here + // For now, we'll just return success since JWT tokens expire naturally + return nil +} - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return errors.New("failed to logout: " + resp.Status) - } - - return nil +func (u *authUsecase) ValidateToken(token string) (string, error) { + _, username, err := utils.ValidateJWT(token) + if err != nil { + return "", err + } + + // Verify user still exists and is approved + user, err := u.userRepo.GetUserByUsername(username) + if err != nil { + return "", errors.New("user not found") + } + + if user.Status != entity.UserStatusApproved { + return "", errors.New("user account is not active") + } + + return username, nil } \ No newline at end of file diff --git a/usecase/backbone_usecase.go b/usecase/backbone_usecase.go index 79afb69..8248084 100644 --- a/usecase/backbone_usecase.go +++ b/usecase/backbone_usecase.go @@ -4,89 +4,296 @@ import ( "fmt" "time" "users_management/m/model/dto/req" + "users_management/m/model/dto/res" "users_management/m/model/entity" "users_management/m/repository" + "users_management/m/utils/helper" "github.com/go-playground/validator/v10" "github.com/google/uuid" + "gorm.io/gorm" ) type BackboneUseCase interface { CreateBackbone(backbone req.BackboneDTO) error - GetAllBackbone() ([]entity.Backbone, error) + GetAllBackbone() ([]res.BackboneResponse, error) - GetByID(id uuid.UUID) (entity.Backbone, error) + GetByID(id uuid.UUID) (res.BackboneResponse, error) UpdateBackbone(id uuid.UUID, backbone req.UpdateBackboneDTO) error + } type backboneUseCase struct { backboneRepo repository.BackboneRepo + fishboneRepo repository.FishboneRepo validate *validator.Validate + deviceDetailsRepo repository.DeviceDetailsRepo // Add this field } -func NewBackboneUseCase(backboneRepo repository.BackboneRepo) BackboneUseCase { - return &backboneUseCase{ - backboneRepo: backboneRepo, - validate: validator.New(), - } +func NewBackboneUseCase(backboneRepo repository.BackboneRepo, fishboneRepo repository.FishboneRepo, deviceDetailsRepo repository.DeviceDetailsRepo) BackboneUseCase { + return &backboneUseCase{ + backboneRepo: backboneRepo, + fishboneRepo: fishboneRepo, + deviceDetailsRepo: deviceDetailsRepo, // Initialize the field + validate: validator.New(), + } } func (u *backboneUseCase) CreateBackbone(backbone req.BackboneDTO) error { - err := u.validate.Struct(backbone) - if err != nil { - return fmt.Errorf("validation error: %w", err) - } + err := u.validate.Struct(backbone) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } - newBackbone := entity.Backbone{ - ID: uuid.New(), - DeviceStartID: backbone.DeviceStartID, - DeviceEndID: backbone.DeviceEndID, - CoreAmount: backbone.CoreAmount, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } + return u.backboneRepo.WithTransaction(func(tx *gorm.DB) error { + // Lock device records to prevent race conditions + var startDevice entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", backbone.DeviceStartID).First(&startDevice).Error; err != nil { + return fmt.Errorf("start device not found: %w", err) + } + + var endDevice entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", backbone.DeviceEndID).First(&endDevice).Error; err != nil { + return fmt.Errorf("end device not found: %w", err) + } - - return u.backboneRepo.Post(newBackbone) + // Validate device types - both devices must be OTB for backbones + if startDevice.DeviceType != "OTB" { + return fmt.Errorf("start device must be of type OTB, got %s", startDevice.DeviceType) + } + + if endDevice.DeviceType != "OTB" { + return fmt.Errorf("end device must be of type OTB, got %s", endDevice.DeviceType) + } + + // Check port availability with locking + var startDevicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", backbone.DeviceStartID).First(&startDevicePort).Error; err != nil { + return fmt.Errorf("start device port record not found: %w", err) + } + + var endDevicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", backbone.DeviceEndID).First(&endDevicePort).Error; err != nil { + return fmt.Errorf("end device port record not found: %w", err) + } + + // Validate port availability - each backbone uses 1 port regardless of core amount + if startDevicePort.PortAvailable < 1 && startDevice.DeviceType == "OTB" { + return fmt.Errorf("start device has no available ports (available: %d, required: 1)", + startDevicePort.PortAvailable) + } + + if endDevicePort.PortAvailable < 1 { + return fmt.Errorf("end device has no available ports (available: %d, required: 1)", + endDevicePort.PortAvailable) + } + + newBackbone := entity.Backbone{ + ID: uuid.New(), + BackboneCode: backbone.BackboneCode, + DeviceStartID: backbone.DeviceStartID, + DeviceEndID: backbone.DeviceEndID, + CoreAmount: backbone.CoreAmount, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Create backbone + if err := tx.Create(&newBackbone).Error; err != nil { + return err + } + + // Update port usage for both devices + if err := u.updateDevicePortUsageInTx(tx, backbone.DeviceStartID); err != nil { + return fmt.Errorf("failed to update start device port usage: %w", err) + } + + if err := u.updateDevicePortUsageInTx(tx, backbone.DeviceEndID); err != nil { + return fmt.Errorf("failed to update end device port usage: %w", err) + } + + return nil + }) } -func (u *backboneUseCase) GetAllBackbone() ([]entity.Backbone, error) { + +func (u *backboneUseCase) GetAllBackbone() ([]res.BackboneResponse, error) { backbones, err := u.backboneRepo.GetAll() if err != nil { - return backbones, err + return nil, err } - return backbones, nil + totalFishbone,err := u.fishboneRepo.CountFishbone() + if err != nil { + return nil, err + } + + backboneResp, err := helper.ConvertToBackboneResponses(backbones,totalFishbone) + + if err != nil { + return nil, err + } + return backboneResp, nil } -func (u *backboneUseCase) GetByID(id uuid.UUID) (entity.Backbone, error) { +func (u *backboneUseCase) GetByID(id uuid.UUID) (res.BackboneResponse, error) { backbone, err := u.backboneRepo.GetByID(id) if err != nil { - return backbone, err + return res.BackboneResponse{}, err } - return backbone, nil -} + + fishboneCount, err := u.fishboneRepo.CountFishboneByBackboneID(backbone.ID) + + if err != nil { + return res.BackboneResponse{}, err + } + + backboneResp, err := helper.ConvertToBackboneRespId(backbone,fishboneCount) + + if err != nil { + return res.BackboneResponse{}, err + } + + return backboneResp, nil +} func (u *backboneUseCase) UpdateBackbone(id uuid.UUID, backbone req.UpdateBackboneDTO) error { - err := u.validate.Struct(backbone) - if err != nil { - return fmt.Errorf("validation error: %w", err) - } + err := u.validate.Struct(backbone) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } - updates := make(map[string]interface{}) + return u.backboneRepo.WithTransaction(func(tx *gorm.DB) error { + // Get original backbone for comparison with lock + var originalBackbone entity.Backbone + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", id).First(&originalBackbone).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return fmt.Errorf("backbone not found") + } + return err + } - if backbone.DeviceStartID != nil { - updates["DeviceStartID"] = backbone.DeviceStartID - } + updates := make(map[string]interface{}) + devicesToUpdate := make(map[uuid.UUID]bool) + devicesToUpdate[originalBackbone.DeviceStartID] = true + devicesToUpdate[originalBackbone.DeviceEndID] = true - if backbone.DeviceEndID != nil { - updates["DeviceEndID"] = backbone.DeviceEndID - } + // Validate device type changes if devices are being changed + if backbone.DeviceStartID != nil && *backbone.DeviceStartID != originalBackbone.DeviceStartID { + var newStartDevice entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", *backbone.DeviceStartID).First(&newStartDevice).Error; err != nil { + return fmt.Errorf("new start device not found: %w", err) + } + if newStartDevice.DeviceType != "OTB" { + return fmt.Errorf("new start device must be of type OTB, got %s", newStartDevice.DeviceType) + } + updates["dev_start_id"] = *backbone.DeviceStartID + devicesToUpdate[*backbone.DeviceStartID] = true + } - if backbone.CoreAmount != nil { - updates["CoreAmount"] = backbone.CoreAmount - } + if backbone.DeviceEndID != nil && *backbone.DeviceEndID != originalBackbone.DeviceEndID { + var newEndDevice entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", *backbone.DeviceEndID).First(&newEndDevice).Error; err != nil { + return fmt.Errorf("new end device not found: %w", err) + } + if newEndDevice.DeviceType != "OTB" { + return fmt.Errorf("new end device must be of type OTB, got %s", newEndDevice.DeviceType) + } + updates["dev_end_id"] = *backbone.DeviceEndID + devicesToUpdate[*backbone.DeviceEndID] = true + } - updates["UpdatedAt"] = time.Now() + // Handle core amount changes + if backbone.CoreAmount != nil && *backbone.CoreAmount != originalBackbone.CoreAmount { + updates["core_amount"] = *backbone.CoreAmount + } - return u.backboneRepo.Update(id, updates) -} \ No newline at end of file + if len(updates) == 0 { + return fmt.Errorf("no fields to update") + } + + updates["updated_at"] = time.Now() + + // Update backbone + if err := tx.Model(&entity.Backbone{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return err + } + + // Update port usage for all affected devices + for deviceID := range devicesToUpdate { + if err := u.updateDevicePortUsageInTx(tx, deviceID); err != nil { + return fmt.Errorf("failed to update device port usage for device %s: %w", deviceID, err) + } + } + + return nil + }) +} + +func (u *backboneUseCase) updateDevicePortUsageInTx(tx *gorm.DB, deviceID uuid.UUID) error { + // Get device with lock + var device entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", deviceID).First(&device).Error; err != nil { + return err + } + + var portUsed int + var customerCount int + + switch device.DeviceType { + case "OTB": + // For OTB: count backbones (each backbone uses 1 port regardless of core amount) + var backboneCount int64 + if err := tx.Model(&entity.Backbone{}). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Count(&backboneCount).Error; err != nil { + return err + } + portUsed = int(backboneCount) + customerCount = 0 // OTB doesn't serve customers directly + + case "closure": + // For closure: count fishbones where this device is the start device + var fishboneCount int64 + if err := tx.Model(&entity.Fishbone{}). + Where("dev_start_id = ?", deviceID). + Count(&fishboneCount).Error; err != nil { + return err + } + portUsed = int(fishboneCount) + customerCount = 0 // Closure doesn't serve customers directly + + case "ODP": + // For ODP: sum fishbone core amounts where this device is the end device + var totalCores int64 + if err := tx.Model(&entity.Fishbone{}). + Where("dev_end_id = ?", deviceID). + Select("COALESCE(SUM(core_amount), 0)"). + Scan(&totalCores).Error; err != nil { + return err + } + portUsed = int(totalCores) + customerCount = portUsed // For ODP, customer count equals port_used + } + + portAvailable := device.PortAmount - portUsed + if portAvailable < 0 { + portAvailable = 0 + } + + return tx.Model(&entity.DevicePort{}). + Where("device_id = ?", deviceID). + Updates(map[string]interface{}{ + "port_used": portUsed, + "port_available": portAvailable, + "customer_count": customerCount, + "updated_at": gorm.Expr("NOW()"), + }).Error +} diff --git a/usecase/count_assets_usecase.go b/usecase/count_assets_usecase.go new file mode 100644 index 0000000..6f8882f --- /dev/null +++ b/usecase/count_assets_usecase.go @@ -0,0 +1,31 @@ +package usecase + +import ( + "users_management/m/model/entity" + "users_management/m/repository" +) + +type CountAssetsUseCase interface { + GetCounts() (entity.CountAssets, error) +} + +type countAssetsUseCase struct { + countAssetsRepo repository.CountAssetsRepo +} + +func NewCountAssetsUseCase(countAssetsRepo repository.CountAssetsRepo) CountAssetsUseCase { + return &countAssetsUseCase{ + countAssetsRepo: countAssetsRepo, + } +} + +func (uc *countAssetsUseCase) GetCounts() (entity.CountAssets, error) { + // Update counts before returning + err := uc.countAssetsRepo.UpdateCounts() + if err != nil { + return entity.CountAssets{}, err + } + + // Get the updated counts + return uc.countAssetsRepo.Get() +} \ No newline at end of file diff --git a/usecase/devicePort_usecase.go b/usecase/devicePort_usecase.go index 06cb3a5..c449afd 100644 --- a/usecase/devicePort_usecase.go +++ b/usecase/devicePort_usecase.go @@ -39,7 +39,6 @@ func (u *devicePortUseCase) CreateDevicePort(devicePort req.DevicePort) error { newDevicePort := entity.DevicePort{ ID: uuid.New(), DeviceID: devicePort.DeviceID, - PortNumber: devicePort.PortNumber, CreatedAt: time.Now(), UpdatedAt: time.Now(), } diff --git a/usecase/device_details.go b/usecase/device_details.go new file mode 100644 index 0000000..4f2de14 --- /dev/null +++ b/usecase/device_details.go @@ -0,0 +1,617 @@ +package usecase + +import ( + "errors" + "fmt" + "log" + "mime/multipart" + "strings" + "time" + "users_management/m/model/dto/req" + "users_management/m/model/dto/res" + "users_management/m/model/entity" + "users_management/m/repository" + "users_management/m/utils/helper" + "users_management/m/utils/service" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" +) + +type DeviceDetailsUseCase interface { + CreateDeviceDetails(device req.DeviceDetailsDTO) error + GetAllDeviceDetails() ([]res.DeviceDetailsResponse, error) + GetDeviceDetailsByID(id uuid.UUID) (res.DeviceDetailsResponse, error) + UpdateDeviceDetails(id uuid.UUID, device req.UpdateDeviceDetailsDTO) error + DeleteDeviceDetails(id uuid.UUID) error + + // Port management + // ValidatePortUsage(deviceID uuid.UUID, requiredPorts int) error + RecalculatePortUsage(deviceID uuid.UUID) error + AssignCustomerToPort(deviceID uuid.UUID, customerName string,portNumber *int) error + UpdatePortUsage(deviceID uuid.UUID, portUsed int) error + UpdatePortAssignments(deviceID uuid.UUID, portAssignments []req.PortAssignmentDTO) error + AssignMultipleCustomersToPort(deviceID uuid.UUID, assignments []req.AssignMultipleCustomersDTO) error + 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 + DeleteDeviceImage(deviceID uuid.UUID, filename string) error + GetDevicesWithoutTowers(deviceTypes []string) ([]res.DeviceDetailsResponse, error) + GetDevicesWithoutConnections(deviceTypes []string) ([]res.DeviceDetailsResponse, error) + ValidateOLTExists(oltID uuid.UUID) (bool, error) +} + +type deviceDetailsUseCase struct { + deviceDetailsRepo repository.DeviceDetailsRepo + oltRepo repository.OLTRepo + geocoder service.GeocodingService + validate *validator.Validate +} + +func NewDeviceDetailsUseCase(deviceDetailsRepo repository.DeviceDetailsRepo, oltRepo repository.OLTRepo,geocoder service.GeocodingService) DeviceDetailsUseCase { + return &deviceDetailsUseCase{ + deviceDetailsRepo: deviceDetailsRepo, + oltRepo: oltRepo, + geocoder: geocoder, + validate: validator.New(), + } +} + +func (u *deviceDetailsUseCase) ValidateOLTExists(oltID uuid.UUID) (bool, error) { + _, err := u.oltRepo.GetByID(oltID) + if err != nil { + return false, nil + } + return true, nil +} + + +func (u *deviceDetailsUseCase) GetDevicesWithoutConnections(deviceTypes []string) ([]res.DeviceDetailsResponse, error) { + // Validate device types and convert to uppercase + validTypes := map[string]bool{"CLOSURE": true, "OTB": true} + for i, deviceType := range deviceTypes { + upperDeviceType := strings.ToUpper(deviceType) + if !validTypes[upperDeviceType] { + return nil, fmt.Errorf("invalid device type: %s. Only CLOSURE and OTB are allowed", deviceType) + } + // Replace the original value in the slice with uppercase version for consistency + deviceTypes[i] = upperDeviceType + } + + // If no device types provided, default to closure and OTB + if len(deviceTypes) == 0 { + deviceTypes = []string{"CLOSURE", "OTB"} + } + + log.Printf("Fetching devices without connections for types: %v", deviceTypes) + + devices, err := u.deviceDetailsRepo.GetDevicesWithoutConnections(deviceTypes) + if err != nil { + return nil, err + } + + responses, err := helper.ConvertToDeviceDetailsResponses(devices, u.geocoder) + if err != nil { + return nil, err + } + + return responses, nil +} + +func (u *deviceDetailsUseCase) DeleteDeviceImage(deviceID uuid.UUID, filename string) error { + // Get current device + currentDevice, err := u.deviceDetailsRepo.GetByID(deviceID) + if err != nil { + return fmt.Errorf("device not found") + } + + // Get all current image URLs + allImageURLs := currentDevice.GetAllImageURLs() + + // Find the image URL that contains the filename + var imageURLToDelete string + var updatedImageURLs []string + + for _, imageURL := range allImageURLs { + if strings.Contains(imageURL, filename) { + imageURLToDelete = imageURL + } else { + updatedImageURLs = append(updatedImageURLs, imageURL) + } + } + + // Check if image was found + if imageURLToDelete == "" { + return fmt.Errorf("image not found in device") + } + + // Don't allow deleting the last image if it's the primary image + if len(allImageURLs) == 1 && currentDevice.ImageURL != nil && *currentDevice.ImageURL == imageURLToDelete { + return fmt.Errorf("cannot delete the only remaining image") + } + + // Prepare updates + updates := map[string]interface{}{ + "updated_at": time.Now(), + } + + if len(updatedImageURLs) == 0 { + // No images left + updates["image_url"] = nil + updates["image_urls"] = entity.StringSlice([]string{}) + } else { + // Update primary image if it was deleted + if currentDevice.ImageURL != nil && *currentDevice.ImageURL == imageURLToDelete { + updates["image_url"] = updatedImageURLs[0] // Set first remaining image as primary + } + // Update all images + updates["image_urls"] = entity.StringSlice(updatedImageURLs) + } + + // Update database + err = u.deviceDetailsRepo.Update(deviceID, updates) + if err != nil { + return fmt.Errorf("failed to update device in database: %w", err) + } + + // Delete the actual file from filesystem + err = helper.DeleteDeviceImage(imageURLToDelete) + if err != nil { + // Log error but don't fail the request since database was already updated + fmt.Printf("Warning: Failed to delete image file %s: %v\n", imageURLToDelete, err) + } + + return nil +} + +func (u *deviceDetailsUseCase) GetDevicesWithoutTowers(deviceTypes []string) ([]res.DeviceDetailsResponse, error) { + // Validate device types + validTypes := map[string]bool{"ODP": true, "OTB": true} + for _, deviceType := range deviceTypes { + if !validTypes[deviceType] { + return nil, fmt.Errorf("invalid device type: %s. Only ODP and OTB are allowed", deviceType) + } + } + + // If no device types provided, default to ODP and OTB + if len(deviceTypes) == 0 { + deviceTypes = []string{"ODP", "OTB"} + } + + devices, err := u.deviceDetailsRepo.GetDevicesWithoutTowers(deviceTypes) + if err != nil { + return nil, err + } + + responses, err := helper.ConvertToDeviceDetailsResponses(devices, u.geocoder) + if err != nil { + return nil, err + } + + return responses, nil +} + +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 { + if *deviceDTO.PortAmount < 0 { + return fmt.Errorf("port amount cannot be negative") + } + + 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 + } + + if deviceDTO.TowerID != nil { + towerExists, err := u.deviceDetailsRepo.ValidateTowerExists(*deviceDTO.TowerID) + if err != nil { + return fmt.Errorf("failed to validate tower: %w", err) + } + if !towerExists { + return fmt.Errorf("tower with ID %s not found", deviceDTO.TowerID.String()) + } + updates["tower_id"] = *deviceDTO.TowerID + } + + // Handle OLTID validation and update + if deviceDTO.OLTID!= nil { + // Get current device to check type + currentDevice, err := u.deviceDetailsRepo.GetByID(id) + if err != nil { + return fmt.Errorf("device not found: %w", err) + } + + // Only ODP devices can be assigned to OLT + deviceType := string(currentDevice.DeviceType) + if deviceDTO.DeviceType != nil { + deviceType = *deviceDTO.DeviceType + } + + if deviceType != "ODP" { + return fmt.Errorf("only ODP devices can be assigned to OLT") + } + + oltExists, err := u.ValidateOLTExists(*deviceDTO.OLTID) + if err != nil { + return fmt.Errorf("failed to validate OLT: %w", err) + } + if !oltExists { + return fmt.Errorf("OLT with ID %s not found", deviceDTO.OLTID.String()) + } + updates["olt_id"] = *deviceDTO.OLTID + } + + // 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 + 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["image_url"] = finalImageURLs[0] + } + + // Update all images using image_urls column (not additional_images) + updates["image_urls"] = 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 { + return errors.New("port number must be greater than 0") + } + + return u.deviceDetailsRepo.UpdateCustomerByPort(deviceID, update) +} + +func (u *deviceDetailsUseCase) BulkUpdateCustomersByPort(deviceID uuid.UUID, updates []req.UpdateCustomerByPortDTO) error { + if len(updates) == 0 { + return errors.New("no updates provided") + } + + // Validate all updates + portNumbers := make(map[int]bool) + newCustomerNames := make(map[string]bool) + + for _, update := range updates { + // Check for duplicate port numbers in request + if portNumbers[update.PortNumber] { + return fmt.Errorf("duplicate port number in request: %d", update.PortNumber) + } + portNumbers[update.PortNumber] = true + + // Check for duplicate new customer names in request (ignore nulls) + if update.NewCustomerName != nil && *update.NewCustomerName != "" { + if newCustomerNames[*update.NewCustomerName] { + return fmt.Errorf("duplicate new customer name in request: %s", *update.NewCustomerName) + } + newCustomerNames[*update.NewCustomerName] = true + } + + // Validate port number + if update.PortNumber < 1 { + return fmt.Errorf("port number must be greater than 0: %d", update.PortNumber) + } + } + + return u.deviceDetailsRepo.BulkUpdateCustomersByPort(deviceID, updates) +} + +func (u *deviceDetailsUseCase) RemoveCustomerByPort(deviceID uuid.UUID, portNumber int) error { + if portNumber < 1 { + return errors.New("port number must be greater than 0") + } + + return u.deviceDetailsRepo.RemoveCustomerByPort(deviceID, portNumber) +} + +func (u *deviceDetailsUseCase) AssignMultipleCustomersToPort(deviceID uuid.UUID, assignments []req.AssignMultipleCustomersDTO) error { + if len(assignments) == 0 { + return errors.New("no assignments provided") + } + + // Validate all assignments first + customerNames := make(map[string]bool) + portNumbers := make(map[int]bool) + + for _, assignment := range assignments { + // Check for duplicate customer names in the request + if customerNames[assignment.CustomerName] { + return fmt.Errorf("duplicate customer name in request: %s", assignment.CustomerName) + } + customerNames[assignment.CustomerName] = true + + // Check for duplicate port numbers in the request + if assignment.PortNumber != nil { + if portNumbers[*assignment.PortNumber] { + return fmt.Errorf("duplicate port number in request: %d", *assignment.PortNumber) + } + portNumbers[*assignment.PortNumber] = true + } + } + + return u.deviceDetailsRepo.AssignMultipleCustomersToPort(deviceID, assignments) +} + + +func (u *deviceDetailsUseCase) UpdatePortAssignments(deviceID uuid.UUID, assignments []req.PortAssignmentDTO) error { + // Validate the assignments + if len(assignments) == 0 { + return errors.New("port assignments cannot be empty") + } + + // Check for duplicate port numbers + portNumbers := make(map[int]bool) + for _, assignment := range assignments { + if portNumbers[assignment.PortNumber] { + return fmt.Errorf("duplicate port number: %d", assignment.PortNumber) + } + portNumbers[assignment.PortNumber] = true + } + + return u.deviceDetailsRepo.UpdatePortAssignments(deviceID, assignments) +} + + +func (u *deviceDetailsUseCase) UpdatePortUsage(deviceID uuid.UUID, portUsed int) error { + return u.deviceDetailsRepo.UpdatePortUsage(deviceID, portUsed) +} + + + +func (u *deviceDetailsUseCase) CreateDeviceDetails(deviceDTO req.DeviceDetailsDTO) error { + err := u.validate.Struct(deviceDTO) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + // Validate tower exists if TowerID is provided + if deviceDTO.TowerID != nil { + towerExists, err := u.deviceDetailsRepo.ValidateTowerExists(*deviceDTO.TowerID) + if err != nil { + return fmt.Errorf("failed to validate tower: %w", err) + } + if !towerExists { + return fmt.Errorf("tower with ID %s not found", deviceDTO.TowerID.String()) + } + } + + newDevice := entity.Device{ + ID: uuid.New(), + DeviceCode: deviceDTO.DeviceCode, + DeviceType: entity.DeviceType(deviceDTO.DeviceType), + Longitude: deviceDTO.Longitude, + Latitude: deviceDTO.Latitude, + PortAmount: deviceDTO.PortAmount, + Status: entity.DeviceStatus(deviceDTO.Status), + Province: deviceDTO.Province, + City: deviceDTO.City, + District: deviceDTO.District, + TowerID: deviceDTO.TowerID, // Add TowerID + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + return u.deviceDetailsRepo.Create(newDevice) +} + +func (u *deviceDetailsUseCase) GetAllDeviceDetails() ([]res.DeviceDetailsResponse, error) { + devices, err := u.deviceDetailsRepo.GetAll() + if err != nil { + return nil, err + } + + return helper.ConvertToDeviceDetailsResponses(devices, u.geocoder) +} + +func (u *deviceDetailsUseCase) GetDeviceDetailsByID(id uuid.UUID) (res.DeviceDetailsResponse, error) { + device, err := u.deviceDetailsRepo.GetByID(id) + if err != nil { + return res.DeviceDetailsResponse{}, err + } + + return helper.ConvertToDeviceDetailsResponse(device, u.geocoder) +} + +func (u *deviceDetailsUseCase) UpdateDeviceDetails(id uuid.UUID, deviceDTO req.UpdateDeviceDetailsDTO) 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 + } + if deviceDTO.TowerID != nil { + // Validate tower exists + towerExists, err := u.deviceDetailsRepo.ValidateTowerExists(*deviceDTO.TowerID) + if err != nil { + return fmt.Errorf("failed to validate tower: %w", err) + } + if !towerExists { + return fmt.Errorf("tower with ID %s not found", deviceDTO.TowerID.String()) + } + updates["tower_id"] = *deviceDTO.TowerID + } + + if deviceDTO.OLTID != nil { + // Validate OLT exists + oltExists, err := u.ValidateOLTExists(*deviceDTO.OLTID) + if err != nil { + return fmt.Errorf("failed to validate OLT: %w", err) + } + if !oltExists { + return fmt.Errorf("OLT with ID %s not found", deviceDTO.OLTID.String()) + } + + updates["olt_id"] = *deviceDTO.OLTID + } + + log.Printf("Updating device %s with fields: %+v", id, updates) + + 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) DeleteDeviceDetails(id uuid.UUID) error { + // Check if device has connections + backbones, err := u.deviceDetailsRepo.GetBackbonesByDeviceID(id) + if err != nil { + return err + } + if len(backbones) > 0 { + return errors.New("cannot delete device with active backbone connections") + } + + fishbones, err := u.deviceDetailsRepo.GetFishbonesByDeviceID(id) + if err != nil { + return err + } + if len(fishbones) > 0 { + return errors.New("cannot delete device with active fishbone connections") + } + + towers, err := u.deviceDetailsRepo.GetTowersByDeviceID(id) + if err != nil { + return err + } + if len(towers) > 0 { + return errors.New("cannot delete device with active tower connections") + } + + return u.deviceDetailsRepo.Delete(id) +} + + +func (u *deviceDetailsUseCase) RecalculatePortUsage(deviceID uuid.UUID) error { + return u.deviceDetailsRepo.UpdateDevicePortUsage(deviceID) +} + +func (u *deviceDetailsUseCase) AssignCustomerToPort(deviceID uuid.UUID, customerName string, portNumber *int) error { + return u.deviceDetailsRepo.AssignCustomerToPort(deviceID, customerName, portNumber) +} \ No newline at end of file diff --git a/usecase/device_inspection.go b/usecase/device_inspection.go new file mode 100644 index 0000000..38d122a --- /dev/null +++ b/usecase/device_inspection.go @@ -0,0 +1,414 @@ +package usecase + +import ( + "errors" + "fmt" + "log" + "strconv" + "time" + "users_management/m/model/dto/req" + "users_management/m/model/dto/res" + "users_management/m/model/entity" + "users_management/m/repository" + "users_management/m/utils/helper" + "users_management/m/utils/service" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" +) + +type DeviceInspectionUseCase interface { + CreateInspection(userID uuid.UUID, inspection req.DeviceInspectionDTO) error + GetAllInspections(page, limit int) ([]res.DeviceInspectionResponse, int64, error) + GetUserInspections(userID uuid.UUID, page, limit int) ([]res.DeviceInspectionResponse, int64, error) + GetInspectionByID(id uuid.UUID) (res.DeviceInspectionDetailResponse, error) + + + UpdateInspection(id, userID uuid.UUID, inspection req.UpdateDeviceInspectionDTO, userRole string) error + ApproveInspection(id uuid.UUID, approval req.ApproveInspectionDTO) error + CheckInspectionOwnership(inspectionID, userID uuid.UUID) (bool, error) // Add this line +} + +type deviceInspectionUseCase struct { + inspectionRepo repository.DeviceInspectionRepo + activityLogUC ActivityLogUseCase + geoService service.GeocodingService + validate *validator.Validate +} + +func NewDeviceInspectionUseCase(inspectionRepo repository.DeviceInspectionRepo, activityLogUC ActivityLogUseCase, geoService service.GeocodingService) DeviceInspectionUseCase { + return &deviceInspectionUseCase{ + inspectionRepo: inspectionRepo, + activityLogUC: activityLogUC, + geoService: geoService, + validate: validator.New(), + } +} + + +func (u *deviceInspectionUseCase) CheckInspectionOwnership(inspectionID, userID uuid.UUID) (bool, error) { + return u.inspectionRepo.CheckOwnership(inspectionID, userID) +} + +func (u *deviceInspectionUseCase) CreateInspection(userID uuid.UUID, inspection req.DeviceInspectionDTO) error { + err := u.validate.Struct(inspection) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + // Check if device exists + deviceExists, err := u.inspectionRepo.CheckDeviceExists(inspection.DeviceID) + if err != nil { + return err + } + if !deviceExists { + return errors.New("device not found") + } + + // Get device details + device, err := u.inspectionRepo.GetDeviceByID(inspection.DeviceID) + if err != nil { + return err + } + + // Validate device type constraints + if device.DeviceType == "OTB" && inspection.BackboneID == nil { + return errors.New("backbone is required for OTB device type") + } + if device.DeviceType == "ODP" && inspection.FishboneID == nil { + return errors.New("fishbone is required for ODP device type") + } + + // Validate backbone exists if provided + if inspection.BackboneID != nil { + backboneExists, err := u.inspectionRepo.CheckBackboneExists(*inspection.BackboneID) + if err != nil { + return err + } + if !backboneExists { + return errors.New("backbone not found") + } + } + + // Validate fishbone exists if provided + if inspection.FishboneID != nil { + fishboneExists, err := u.inspectionRepo.CheckFishboneExists(*inspection.FishboneID) + if err != nil { + return err + } + if !fishboneExists { + return errors.New("fishbone not found") + } + } + + // Validate tower exists if provided + if inspection.TowerID != nil { + towerExists, err := u.inspectionRepo.CheckTowerExists(*inspection.TowerID) + if err != nil { + return err + } + if !towerExists { + return errors.New("tower not found") + } + } + + // Check port availability + portUsedInt, err := strconv.Atoi(inspection.PortUsed) + if err != nil { + return errors.New("invalid port_used value") + } + + // Get current device port usage + devicePort, err := u.inspectionRepo.GetDevicePortByDeviceID(inspection.DeviceID) + if err != nil { + // If device port doesn't exist, create it + return fmt.Errorf("device port not found for device %s: %w", inspection.DeviceID, err) + } + // Check if adding this inspection would exceed port capacity + newPortUsed := devicePort.PortUsed + portUsedInt + if newPortUsed > device.PortAmount { + return fmt.Errorf("insufficient ports available. Device has %d ports, currently %d used, requested %d additional", + device.PortAmount, devicePort.PortUsed, portUsedInt) + } + + // Calculate cable amount based on device type + cableAmount, err := u.inspectionRepo.CountCablesByDevice(inspection.DeviceID, string(device.DeviceType)) + if err != nil { + return err + } + + // Calculate port available + portAvailable := device.PortAmount - newPortUsed + + // Get address from coordinates using OpenStreetMap + longitude := inspection.InspectionPlacement.Longitude + latitude := inspection.InspectionPlacement.Latitude + + address, err := u.geoService.GetAddressFromCoordinates(latitude, longitude) + if err != nil { + log.Printf("Error getting address from coordinates (%.6f, %.6f): %v", latitude, longitude, err) + address = fmt.Sprintf("Coordinates: %.6f, %.6f", latitude, longitude) // Fallback to coordinates + } + + // Create inspection + newInspection := entity.DeviceInspection{ + ID: uuid.New(), + DeviceID: inspection.DeviceID, + BackboneID: inspection.BackboneID, + FishboneID: inspection.FishboneID, + TowerID: inspection.TowerID, + UserID: userID, + Status: inspection.Status, + PortUsed: inspection.PortUsed, + PortAvailable: strconv.Itoa(portAvailable), + CableAmount: cableAmount, + Description: inspection.Description, + ImageURL: inspection.ImageURL, + InspectionPlacement: address, // Store the full address from OpenStreetMap + Longitude: longitude, // Store longitude + Latitude: latitude, // Store latitude + InspectionApproval: "pending", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err = u.inspectionRepo.Create(newInspection) + if err != nil { + return err + } + + // Log activity + inspectionIDStr := newInspection.ID.String() + u.activityLogUC.LogActivity( + userID, + "CREATE", + "device_inspection", + &inspectionIDStr, + nil, + fmt.Sprintf("Created device inspection for device %s at %s", device.DeviceCode, address), + "", + "", + "", + ) + + return nil +} + +func (u *deviceInspectionUseCase) UpdateInspection(id, userID uuid.UUID, inspection req.UpdateDeviceInspectionDTO, userRole string) error { + err := u.validate.Struct(inspection) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + // Get existing inspection + existingInspection, err := u.inspectionRepo.GetByID(id) + if err != nil { + return errors.New("inspection not found") + } + + // Check if user owns this inspection (for teknisi) + if userRole == "Teknisi" && existingInspection.UserID != userID { + return errors.New("unauthorized: you can only update your own inspections") + } + + updates := make(map[string]interface{}) + + if inspection.BackboneID != nil { + backboneExists, err := u.inspectionRepo.CheckBackboneExists(*inspection.BackboneID) + if err != nil { + return err + } + if !backboneExists { + return errors.New("backbone not found") + } + updates["backbone_id"] = *inspection.BackboneID + } + + if inspection.FishboneID != nil { + fishboneExists, err := u.inspectionRepo.CheckFishboneExists(*inspection.FishboneID) + if err != nil { + return err + } + if !fishboneExists { + return errors.New("fishbone not found") + } + updates["fishbone_id"] = *inspection.FishboneID + } + + if inspection.TowerID != nil { + towerExists, err := u.inspectionRepo.CheckTowerExists(*inspection.TowerID) + if err != nil { + return err + } + if !towerExists { + return errors.New("tower not found") + } + updates["tower_id"] = *inspection.TowerID + } + + if inspection.Status != nil { + updates["status"] = *inspection.Status + } + + if inspection.PortUsed != nil { + updates["port_used"] = *inspection.PortUsed + } + + if inspection.Description != nil { + updates["description"] = *inspection.Description + } + + if inspection.ImageURL != nil { + updates["image_url"] = *inspection.ImageURL + } + + // Handle location update + if inspection.InspectionPlacement != nil { + longitude := inspection.InspectionPlacement.Longitude + latitude := inspection.InspectionPlacement.Latitude + + // Get new address from coordinates + address, err := u.geoService.GetAddressFromCoordinates(latitude, longitude) + if err != nil { + log.Printf("Error getting address from coordinates (%.6f, %.6f): %v", latitude, longitude, err) + address = fmt.Sprintf("Coordinates: %.6f, %.6f", latitude, longitude) // Fallback to coordinates + } + + updates["inspection_placement"] = address + updates["longitude"] = longitude + updates["latitude"] = latitude + } + + if len(updates) == 0 { + return errors.New("no fields to update") + } + + updates["updated_at"] = time.Now() + + return u.inspectionRepo.Update(id, updates) +} + +// Keep the rest of the methods unchanged... +func (u *deviceInspectionUseCase) GetAllInspections(page, limit int) ([]res.DeviceInspectionResponse, int64, error) { + offset := (page - 1) * limit + + inspections, err := u.inspectionRepo.GetAll() + if err != nil { + return nil, 0, err + } + + total, err := u.inspectionRepo.CountAll() + if err != nil { + return nil, 0, err + } + + // Apply pagination manually since we need all data for processing + start := offset + end := offset + limit + if start > len(inspections) { + start = len(inspections) + } + if end > len(inspections) { + end = len(inspections) + } + + paginatedInspections := inspections[start:end] + responses := helper.ConvertToDeviceInspectionResponses(paginatedInspections) + + return responses, total, nil +} + +func (u *deviceInspectionUseCase) GetUserInspections(userID uuid.UUID, page, limit int) ([]res.DeviceInspectionResponse, int64, error) { + offset := (page - 1) * limit + + inspections, err := u.inspectionRepo.GetByUserID(userID, limit, offset) + if err != nil { + return nil, 0, err + } + + total, err := u.inspectionRepo.CountByUserID(userID) + if err != nil { + return nil, 0, err + } + + responses := helper.ConvertToDeviceInspectionResponses(inspections) + + return responses, total, nil +} + +func (u *deviceInspectionUseCase) GetInspectionByID(id uuid.UUID) (res.DeviceInspectionDetailResponse, error) { + inspection, err := u.inspectionRepo.GetByID(id) + if err != nil { + return res.DeviceInspectionDetailResponse{}, err + } + + response := helper.ConvertToDeviceInspectionDetailResponse(inspection) + return response, nil +} + +func (u *deviceInspectionUseCase) ApproveInspection(id uuid.UUID, approval req.ApproveInspectionDTO) error { + err := u.validate.Struct(approval) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + // Get inspection details + inspection, err := u.inspectionRepo.GetByID(id) + if err != nil { + return errors.New("inspection not found") + } + + updates := map[string]interface{}{ + "inspection_approval": approval.InspectionApproval, + "updated_at": time.Now(), + } + + // Update inspection + err = u.inspectionRepo.Update(id, updates) + if err != nil { + return err + } + + // If approved, update device port and device status + if approval.InspectionApproval == "approved" { + // Update device status + err = u.inspectionRepo.UpdateDeviceStatus(inspection.DeviceID, inspection.Status) + if err != nil { + return err + } + + // Update device port usage + portUsedInt, _ := strconv.Atoi(inspection.PortUsed) + + devicePort, err := u.inspectionRepo.GetDevicePortByDeviceID(inspection.DeviceID) + if err != nil { + return err + } + + device, err := u.inspectionRepo.GetDeviceByID(inspection.DeviceID) + if err != nil { + return err + } + + newPortUsed := devicePort.PortUsed + portUsedInt + newPortAvailable := device.PortAmount - newPortUsed + + portUpdates := map[string]interface{}{ + "port_used": newPortUsed, + "port_available": newPortAvailable, + "updated_at": time.Now(), + } + + // For OTB devices, also update backbone_id + if device.DeviceType == "OTB" && inspection.BackboneID != nil { + portUpdates["backbone_id"] = *inspection.BackboneID + } + + err = u.inspectionRepo.UpdateDevicePort(inspection.DeviceID, portUpdates) + if err != nil { + return err + } + } + + return nil +} \ No newline at end of file diff --git a/usecase/device_usecase.go b/usecase/device_usecase.go index d6e76b1..a763304 100644 --- a/usecase/device_usecase.go +++ b/usecase/device_usecase.go @@ -2,10 +2,14 @@ package usecase import ( "fmt" + "mime/multipart" "time" "users_management/m/model/dto/req" + "users_management/m/model/dto/res" "users_management/m/model/entity" "users_management/m/repository" + "users_management/m/utils/helper" + "users_management/m/utils/service" "github.com/go-playground/validator/v10" "github.com/google/uuid" @@ -13,59 +17,335 @@ import ( type DeviceUseCase interface { CreateDevice(device req.DeviceDTO) error - GetAllDevices() ([]entity.Device, error) + GetAllDevices() ([]res.DeviceResponse, error) - GetByID(id uuid.UUID) (entity.Device, error) + 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 + + ValidateTowerExists(towerID uuid.UUID) (bool, error) + ValidateOLTExists(oltID uuid.UUID) (bool, error) + } type deviceUseCase struct { deviceRepo repository.DevicesRepo + oltRepo repository.OLTRepo validate *validator.Validate + geocoder service.GeocodingService } -func NewDeviceUseCase(deviceRepo repository.DevicesRepo) DeviceUseCase { +func NewDeviceUseCase(deviceRepo repository.DevicesRepo,oltRepo repository.OLTRepo ,geocoder service.GeocodingService) DeviceUseCase { return &deviceUseCase{ deviceRepo: deviceRepo, + oltRepo: oltRepo, + geocoder: geocoder, validate: validator.New(), } } -func (u *deviceUseCase) CreateDevice(device req.DeviceDTO) error { - err := u.validate.Struct(device) - if err != nil { - return fmt.Errorf("validation error: %w", err) - } +func (u *deviceUseCase) ValidateOLTExists(oltID uuid.UUID) (bool, error) { + _, err := u.oltRepo.GetByID(oltID) + if err != nil { + return false, nil + } + return true, nil +} + +func (u *deviceUseCase) ValidateTowerExists(towerID uuid.UUID) (bool, error) { + return u.deviceRepo.ValidateTowerExists(towerID) +} + +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) + } + + if device.DeviceType == "OTB" || device.DeviceType == "ODP" { + if device.PortAmount <= 0 { + return fmt.Errorf("port amount must be greater than 0 for OTB or ODP devices") + } + } + + if device.TowerID != nil { + towerExists, err := u.ValidateTowerExists(*device.TowerID) + if err != nil { + return fmt.Errorf("failed to validate tower: %w", err) + } + if !towerExists { + return fmt.Errorf("tower with ID %s not found", device.TowerID.String()) + } + } + + if device.OLTID != nil { + // Only ODP devices can be assigned to OLT + if device.DeviceType != "ODP" { + return fmt.Errorf("only ODP devices can be assigned to OLT") + } + + oltExists, err := u.ValidateOLTExists(*device.OLTID) + if err != nil { + return fmt.Errorf("failed to validate OLT: %w", err) + } + if !oltExists { + return fmt.Errorf("OLT with ID %s not found", device.OLTID.String()) + } + } + + + 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(), + ID: uuid.New(), DeviceCode: device.DeviceCode, DeviceType: entity.DeviceType(device.DeviceType), - Longitude: device.Longitude, - Latitude: device.Latitude, + Longitude: device.Longitude, + Latitude: device.Latitude, PortAmount: device.PortAmount, - Status: entity.DeviceStatus(device.Status), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + Status: entity.DeviceStatus(device.Status), + Province: device.Province, + City: device.City, + District: device.District, + TowerID: device.TowerID, // Add TowerID field + OLTID: device.OLTID, + ImageURL: &primaryImageURL, // Primary image as pointer + ImageURLs: entity.StringSlice(imageURLs), // All images + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } - return u.deviceRepo.Post(newDevice) + return u.deviceRepo.Post(newDevice) } -func (u *deviceUseCase) GetAllDevices() ([]entity.Device, error) { +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 TowerID validation and update + if device.TowerID != nil { + towerExists, err := u.ValidateTowerExists(*device.TowerID) + if err != nil { + return fmt.Errorf("failed to validate tower: %w", err) + } + if !towerExists { + return fmt.Errorf("tower with ID %s not found", device.TowerID.String()) + } + updates["TowerID"] = *device.TowerID + } + + // Handle OLTID validation and update + if device.OLTID != nil { + // Get current device to check type + currentDevice, err := u.deviceRepo.GetByID(id) + if err != nil { + return fmt.Errorf("device not found: %w", err) + } + + // Only ODP devices can be assigned to OLT + deviceType := string(currentDevice.DeviceType) + if device.DeviceType != nil { + deviceType = *device.DeviceType + } + + if deviceType != "ODP" { + return fmt.Errorf("only ODP devices can be assigned to OLT") + } + + oltExists, err := u.ValidateOLTExists(*device.OLTID) + if err != nil { + return fmt.Errorf("failed to validate OLT: %w", err) + } + if !oltExists { + return fmt.Errorf("OLT with ID %s not found", device.OLTID.String()) + } + updates["OLTID"] = *device.OLTID + } + + // 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) { devices, err := u.deviceRepo.GetAll() if err != nil { - return devices, err + return []res.DeviceResponse{}, err } - return devices, nil + devicesResponse,err := helper.ConvertToDeviceResponse(devices, u.geocoder) + if err != nil { + return []res.DeviceResponse{}, err + } + return devicesResponse, nil } -func (u *deviceUseCase) GetByID(id uuid.UUID) (entity.Device, error) { +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 { - return device, err + return res.DeviceResponse{}, err } - return device, nil + + deviceResp, err := helper.ConvertToDeviceResponseId(device, u.geocoder) + if err != nil { + return res.DeviceResponse{}, err + } + return deviceResp, nil } func (u *deviceUseCase) UpdateDevice(id uuid.UUID, device req.UpdateDeviceDTO) error { @@ -73,7 +353,6 @@ func (u *deviceUseCase) UpdateDevice(id uuid.UUID, device req.UpdateDeviceDTO) e if err != nil { return fmt.Errorf("validation error: %w", err) } - updates := map[string]interface{}{} if device.DeviceCode != nil { @@ -91,6 +370,62 @@ func (u *deviceUseCase) UpdateDevice(id uuid.UUID, device req.UpdateDeviceDTO) e 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 TowerID validation and update + if device.TowerID != nil { + towerExists, err := u.ValidateTowerExists(*device.TowerID) + if err != nil { + return fmt.Errorf("failed to validate tower: %w", err) + } + if !towerExists { + return fmt.Errorf("tower with ID %s not found", device.TowerID.String()) + } + updates["TowerID"] = *device.TowerID + } + + + // Handle OLTID validation and update + if device.OLTID != nil { + // Get current device to check type + currentDevice, err := u.deviceRepo.GetByID(id) + if err != nil { + return fmt.Errorf("device not found: %w", err) + } + + // Only ODP devices can be assigned to OLT + deviceType := string(currentDevice.DeviceType) + if device.DeviceType != nil { + deviceType = *device.DeviceType + } + + if deviceType != "ODP" { + return fmt.Errorf("only ODP devices can be assigned to OLT") + } + + oltExists, err := u.ValidateOLTExists(*device.OLTID) + if err != nil { + return fmt.Errorf("failed to validate OLT: %w", err) + } + if !oltExists { + return fmt.Errorf("OLT with ID %s not found", device.OLTID.String()) + } + updates["OLTID"] = *device.OLTID + } + + 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") @@ -98,5 +433,16 @@ func (u *deviceUseCase) UpdateDevice(id uuid.UUID, device req.UpdateDeviceDTO) e updates["UpdatedAt"] = time.Now() + + return u.deviceRepo.Update(id, updates) +} + +func (u *deviceUseCase) GetByType(deviceType string) ([]res.DeviceTypeResponse, error) { + devices, err := u.deviceRepo.GetByType(deviceType) + if err != nil { + return nil, err + } + deviceTypeResponses := helper.ConvertToDeviceTypeResponse(devices) + return deviceTypeResponses, nil } \ No newline at end of file diff --git a/usecase/fishbone_usecase.go b/usecase/fishbone_usecase.go index 2ea3fd5..fa01e0e 100644 --- a/usecase/fishbone_usecase.go +++ b/usecase/fishbone_usecase.go @@ -2,95 +2,318 @@ package usecase import ( "errors" + "fmt" + "strings" "time" "users_management/m/model/dto/req" + "users_management/m/model/dto/res" "users_management/m/model/entity" "users_management/m/repository" + "users_management/m/utils/helper" "github.com/go-playground/validator/v10" "github.com/google/uuid" + "gorm.io/gorm" ) type FishboneUseCase interface { - CreateFishbone(fishbone req.FishboneDTO) error - GetAllFishbone() ([]entity.Fishbone, error) - GetByID(id uuid.UUID) (entity.Fishbone, error) - - UpdateFishbone(id uuid.UUID, fishbone req.UpdateFishboneDTO) error + CreateFishbone(fishbone req.FishboneDTO) error + GetAllFishbone() ([]res.FishboneResponse, error) + GetByID(id uuid.UUID) (res.FishboneDetailResponse, error) + UpdateFishbone(id uuid.UUID, fishbone req.UpdateFishboneDTO) error + DeleteFishbone(id uuid.UUID) error + GetFishboneStats() (map[string]interface{}, error) + GetByBackboneID(backboneID uuid.UUID) ([]res.FishboneResponse, error) } -type fishboneUsecase struct { - fishboneRepo repository.FishboneRepo - validate *validator.Validate +type fishboneUseCase struct { + fishboneRepo repository.FishboneRepo + backboneRepo repository.BackboneRepo + deviceDetailsRepo repository.DeviceDetailsRepo // Add this field + validate *validator.Validate } -func NewFishboneUseCase(fishboneRepo repository.FishboneRepo) FishboneUseCase { - return &fishboneUsecase{ - fishboneRepo: fishboneRepo, - validate: validator.New(), - } +func NewFishboneUseCase(fishboneRepo repository.FishboneRepo, backboneRepo repository.BackboneRepo, deviceDetailsRepo repository.DeviceDetailsRepo) FishboneUseCase { + return &fishboneUseCase{ + fishboneRepo: fishboneRepo, + backboneRepo: backboneRepo, + deviceDetailsRepo: deviceDetailsRepo, // Initialize the field + validate: validator.New(), + } } -func (u *fishboneUsecase) CreateFishbone(fishbone req.FishboneDTO) error { - err := u.validate.Struct(fishbone) - if err != nil { - return err - } +func (u *fishboneUseCase) GetByBackboneID(backboneID uuid.UUID) ([]res.FishboneResponse, error) { + fishbones, err := u.fishboneRepo.GetByBackboneID(backboneID) + if err != nil { + return nil, err + } - newFishbone := entity.Fishbone{ - ID: uuid.New(), - BackboneID: fishbone.BackboneID, - DeviceStartID: fishbone.DeviceStartID, - DeviceEndID: fishbone.DeviceEndID, - CoreAmount: fishbone.CoreAmount, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } + fishboneResp, err := helper.ConvertToFishboneResponses(fishbones, nil) + if err != nil { + return nil, err + } - return u.fishboneRepo.Post(newFishbone) + return fishboneResp, nil } -func (u *fishboneUsecase) GetAllFishbone() ([]entity.Fishbone, error) { - fishbones, err := u.fishboneRepo.GetAll() - if err != nil { - return fishbones, err - } - return fishbones, nil + + +func (u *fishboneUseCase) CreateFishbone(fishbone req.FishboneDTO) error { + err := u.validate.Struct(fishbone) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + return u.fishboneRepo.WithTransaction(func(tx *gorm.DB) error { + // Lock device records to prevent race conditions + var startDevice entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", fishbone.DeviceStartID).First(&startDevice).Error; err != nil { + return fmt.Errorf("start device not found: %w", err) + } + + var endDevice entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", fishbone.DeviceEndID).First(&endDevice).Error; err != nil { + return fmt.Errorf("end device not found: %w", err) + } + + // Validate device types + if strings.ToLower(string(startDevice.DeviceType)) != "closure" { + return fmt.Errorf("start device must be of type closure, got %s", startDevice.DeviceType) + } + + if strings.ToUpper(string(endDevice.DeviceType)) != "ODP" { + return fmt.Errorf("end device must be of type ODP, got %s", endDevice.DeviceType) + } + + // Check port availability with locking + var startDevicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", fishbone.DeviceStartID).First(&startDevicePort).Error; err != nil { + return fmt.Errorf("start device port record not found: %w", err) + } + + var endDevicePort entity.DevicePort + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("device_id = ?", fishbone.DeviceEndID).First(&endDevicePort).Error; err != nil { + return fmt.Errorf("end device port record not found: %w", err) + } + + // Validate port availability + if startDevicePort.PortAvailable < 1 && startDevice.DeviceType == "ODP" { + return fmt.Errorf("start device has no available ports (available: %d, required: 1)", + startDevicePort.PortAvailable) + } + + if endDevicePort.PortAvailable < fishbone.CoreAmount { + return fmt.Errorf("end device has insufficient available ports (available: %d, required: %d)", + endDevicePort.PortAvailable, fishbone.CoreAmount) + } + + newFishbone := entity.Fishbone{ + ID: uuid.New(), + FishboneCode: fishbone.FishboneCode, + BackboneID: fishbone.BackboneID, + DeviceStartID: fishbone.DeviceStartID, + DeviceEndID: fishbone.DeviceEndID, + CoreAmount: fishbone.CoreAmount, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Create fishbone + if err := tx.Create(&newFishbone).Error; err != nil { + return err + } + + // Update port usage for both devices + if err := u.updateDevicePortUsageInTx(tx, fishbone.DeviceStartID); err != nil { + return fmt.Errorf("failed to update start device port usage: %w", err) + } + + if err := u.updateDevicePortUsageInTx(tx, fishbone.DeviceEndID); err != nil { + return fmt.Errorf("failed to update end device port usage: %w", err) + } + + return nil + }) } -func (u *fishboneUsecase) GetByID(id uuid.UUID) (entity.Fishbone, error) { - fishbone, err := u.fishboneRepo.GetByID(id) - if err != nil { - return fishbone, err - } - return fishbone, nil +func (u *fishboneUseCase) updateDevicePortUsageInTx(tx *gorm.DB, deviceID uuid.UUID) error { + // Get device with lock + var device entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", deviceID).First(&device).Error; err != nil { + return err + } + + var portUsed int + var customerCount int + + switch device.DeviceType { + case "closure": + var fishboneCount int64 + if err := tx.Model(&entity.Fishbone{}). + Where("dev_start_id = ?", deviceID). + Count(&fishboneCount).Error; err != nil { + return err + } + portUsed = int(fishboneCount) + customerCount = 0 + + case "ODP": + var totalCores int64 + if err := tx.Model(&entity.Fishbone{}). + Where("dev_end_id = ?", deviceID). + Select("COALESCE(SUM(core_amount), 0)"). + Scan(&totalCores).Error; err != nil { + return err + } + portUsed = int(totalCores) + customerCount = portUsed // For ODP, customer count equals port_used + } + + portAvailable := device.PortAmount - portUsed + if portAvailable < 0 { + portAvailable = 0 + } + + return tx.Model(&entity.DevicePort{}). + Where("device_id = ?", deviceID). + Updates(map[string]interface{}{ + "port_used": portUsed, + "port_available": portAvailable, + "customer_count": customerCount, + "updated_at": gorm.Expr("NOW()"), + }).Error } -func (u *fishboneUsecase) UpdateFishbone(id uuid.UUID, fishbone req.UpdateFishboneDTO) error { - err := u.validate.Struct(fishbone) - if err != nil { - return err - } +func (u *fishboneUseCase) GetAllFishbone() ([]res.FishboneResponse, error) { + fishbones, err := u.fishboneRepo.GetAll() + if err != nil { + return nil, err + } - updates := make(map[string]interface{}) - - if fishbone.BackboneID != nil { - updates["BackboneID"] = *fishbone.BackboneID - } - if fishbone.DeviceStartID != nil { - updates["DeviceStartID"] = *fishbone.DeviceStartID - } - if fishbone.DeviceEndID != nil { - updates["DeviceEndID"] = *fishbone.DeviceEndID - } - if fishbone.CoreAmount != nil { - updates["CoreAmount"] = *fishbone.CoreAmount - } - if len(updates) == 0 { - return errors.New("no fields to update") - } - - updates["UpdatedAt"] = time.Now() - - return u.fishboneRepo.Update(id, updates) + return helper.ConvertToSimpleFishboneResponses(fishbones), nil } + +func (u *fishboneUseCase) GetByID(id uuid.UUID) (res.FishboneDetailResponse, error) { + fishbone, err := u.fishboneRepo.GetByIDWithRelations(id) + if err != nil { + return res.FishboneDetailResponse{}, err + } + + // Convert to detailed response using helper + return helper.ConvertToFishboneDetailResponse(fishbone), nil +} + +func (u *fishboneUseCase) UpdateFishbone(id uuid.UUID, fishbone req.UpdateFishboneDTO) error { + err := u.validate.Struct(fishbone) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + return u.fishboneRepo.WithTransaction(func(tx *gorm.DB) error { + // Get original fishbone for comparison with lock + var originalFishbone entity.Fishbone + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", id).First(&originalFishbone).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return fmt.Errorf("fishbone not found") + } + return err + } + + updates := make(map[string]interface{}) + devicesToUpdate := make(map[uuid.UUID]bool) + devicesToUpdate[originalFishbone.DeviceStartID] = true + devicesToUpdate[originalFishbone.DeviceEndID] = true + + // Validate device type changes if devices are being changed + if fishbone.DeviceStartID != nil && *fishbone.DeviceStartID != originalFishbone.DeviceStartID { + var newStartDevice entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", *fishbone.DeviceStartID).First(&newStartDevice).Error; err != nil { + return fmt.Errorf("new start device not found: %w", err) + } + if newStartDevice.DeviceType != "closure" { + return fmt.Errorf("new start device must be of type closure, got %s", newStartDevice.DeviceType) + } + updates["dev_start_id"] = *fishbone.DeviceStartID + devicesToUpdate[*fishbone.DeviceStartID] = true + } + + if fishbone.DeviceEndID != nil && *fishbone.DeviceEndID != originalFishbone.DeviceEndID { + var newEndDevice entity.Device + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", *fishbone.DeviceEndID).First(&newEndDevice).Error; err != nil { + return fmt.Errorf("new end device not found: %w", err) + } + if newEndDevice.DeviceType != "ODP" { + return fmt.Errorf("new end device must be of type ODP, got %s", newEndDevice.DeviceType) + } + updates["dev_end_id"] = *fishbone.DeviceEndID + devicesToUpdate[*fishbone.DeviceEndID] = true + } + + // Handle core amount changes + if fishbone.CoreAmount != nil && *fishbone.CoreAmount != originalFishbone.CoreAmount { + updates["core_amount"] = *fishbone.CoreAmount + } + + if fishbone.BackboneID != nil { + // Validate backbone exists + var backbone entity.Backbone + if err := tx.Where("id = ?", *fishbone.BackboneID).First(&backbone).Error; err != nil { + return fmt.Errorf("backbone not found: %w", err) + } + updates["bb_id"] = *fishbone.BackboneID + } + + if fishbone.FishboneCode != nil { + updates["fishbone_code"] = *fishbone.FishboneCode + } + + if len(updates) == 0 { + return fmt.Errorf("no fields to update") + } + + updates["updated_at"] = time.Now() + + // Update fishbone + if err := tx.Model(&entity.Fishbone{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return err + } + + // Update port usage for all affected devices + for deviceID := range devicesToUpdate { + if err := u.updateDevicePortUsageInTx(tx, deviceID); err != nil { + return fmt.Errorf("failed to update device port usage for device %s: %w", deviceID, err) + } + } + + return nil + }) +} +func (u *fishboneUseCase) DeleteFishbone(id uuid.UUID) error { + // Check if fishbone exists + exists, err := u.fishboneRepo.CheckFishboneExists(id) + if err != nil { + return err + } + if !exists { + return errors.New("fishbone not found") + } + + return u.fishboneRepo.Delete(id) +} + +func (u *fishboneUseCase) GetFishboneStats() (map[string]interface{}, error) { + fishbones, err := u.fishboneRepo.GetAll() + if err != nil { + return nil, err + } + + return helper.GetFishboneStats(fishbones), nil +} \ No newline at end of file diff --git a/usecase/nearest_device.go b/usecase/nearest_device.go new file mode 100644 index 0000000..c5dd4fd --- /dev/null +++ b/usecase/nearest_device.go @@ -0,0 +1,163 @@ +package usecase + +import ( + "math" + "users_management/m/model/dto/req" + "users_management/m/model/dto/res" + "users_management/m/repository" + "users_management/m/utils/helper" + "users_management/m/utils/service" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" +) + +type NearestDeviceUseCase interface { + GetNearestDevices(request req.NearestDeviceDTO) ([]res.NearestDeviceResponse, error) + GetNearestDeviceByID(id uuid.UUID, userLat, userLng float64) (res.NearestDeviceDetailResponse, error) + GetNearestTowers(request req.NearestTowerDTO) ([]res.NearestTowerResponse, error) + GetNearestTowerByID(id uuid.UUID, userLat, userLng float64) (res.NearestTowerDetailResponse, error) +} + +type nearestDeviceUseCase struct { + nearestDeviceRepo repository.NearestDeviceRepo + geocoder service.GeocodingService + validate *validator.Validate +} + +func NewNearestDeviceUseCase(nearestDeviceRepo repository.NearestDeviceRepo, geocoder service.GeocodingService) NearestDeviceUseCase { + return &nearestDeviceUseCase{ + nearestDeviceRepo: nearestDeviceRepo, + geocoder: geocoder, + validate: validator.New(), + } +} + +func (u *nearestDeviceUseCase) GetNearestTowers(request req.NearestTowerDTO) ([]res.NearestTowerResponse, error) { + err := u.validate.Struct(request) + if err != nil { + return nil, err + } + + // Set defaults + radius := request.Radius + if radius == 0 { + radius = 5.0 // Default 5km + } + + limit := request.Limit + if limit == 0 { + limit = 10 // Default 10 towers + } + + towers, err := u.nearestDeviceRepo.GetNearestTowers( + request.Longitude, + request.Latitude, + radius, + limit, + request.Province, + request.City, + request.District, + ) + if err != nil { + return nil, err + } + + responses, err := helper.ConvertToNearestTowerResponses(towers, u.geocoder) + if err != nil { + return nil, err + } + + return responses, nil +} + +func (u *nearestDeviceUseCase) GetNearestTowerByID(id uuid.UUID, userLat, userLng float64) (res.NearestTowerDetailResponse, error) { + tower, err := u.nearestDeviceRepo.GetTowerByIDWithConnections(id) + if err != nil { + return res.NearestTowerDetailResponse{}, err + } + + // Calculate distance + distance := calculateDistance(userLat, userLng, tower.Latitude, tower.Longitude) + + response, err := helper.ConvertToNearestTowerDetailResponse(tower, distance, u.nearestDeviceRepo, u.geocoder) + if err != nil { + return res.NearestTowerDetailResponse{}, err + } + + return response, nil +} + +func (u *nearestDeviceUseCase) GetNearestDevices(request req.NearestDeviceDTO) ([]res.NearestDeviceResponse, error) { + err := u.validate.Struct(request) + if err != nil { + return nil, err + } + + // Set defaults + radius := request.Radius + if radius == 0 { + radius = 5.0 // Default 5km + } + + limit := request.Limit + if limit == 0 { + limit = 10 // Default 10 devices + } + + devices, err := u.nearestDeviceRepo.GetNearestDevices( + request.Longitude, + request.Latitude, + radius, + limit, + request.Province, + request.City, + request.District, + ) + if err != nil { + return nil, err + } + + // Updated function call - removed userLat, userLng parameters since distance is already calculated + responses, err := helper.ConvertToNearestDeviceResponses(devices, u.nearestDeviceRepo, u.geocoder) + if err != nil { + return nil, err + } + + return responses, nil +} + +func (u *nearestDeviceUseCase) GetNearestDeviceByID(id uuid.UUID, userLat, userLng float64) (res.NearestDeviceDetailResponse, error) { + device, err := u.nearestDeviceRepo.GetDeviceByIDWithConnections(id) + if err != nil { + return res.NearestDeviceDetailResponse{}, err + } + + // Calculate distance + distance := calculateDistance(userLat, userLng, device.Latitude, device.Longitude) + + response, err := helper.ConvertToNearestDeviceDetailResponse(device, distance, u.nearestDeviceRepo, u.geocoder) + if err != nil { + return res.NearestDeviceDetailResponse{}, err + } + + return response, nil +} + +// calculateDistance calculates the distance between two coordinates using Haversine formula +func calculateDistance(lat1, lng1, lat2, lng2 float64) float64 { + const earthRadius = 6371 // Earth's radius in kilometers + + lat1Rad := lat1 * math.Pi / 180 + lng1Rad := lng1 * math.Pi / 180 + lat2Rad := lat2 * math.Pi / 180 + lng2Rad := lng2 * math.Pi / 180 + + dlat := lat2Rad - lat1Rad + dlng := lng2Rad - lng1Rad + + a := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dlng/2)*math.Sin(dlng/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + return earthRadius * c +} \ No newline at end of file diff --git a/usecase/olt_usecase.go b/usecase/olt_usecase.go new file mode 100644 index 0000000..f3bd760 --- /dev/null +++ b/usecase/olt_usecase.go @@ -0,0 +1,234 @@ +package usecase + +import ( + "errors" + "fmt" + "log" + "time" + "users_management/m/model/dto/req" + "users_management/m/model/dto/res" + "users_management/m/model/entity" + "users_management/m/repository" + "users_management/m/utils/helper" + "users_management/m/utils/service" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" +) + +type OLTUsecase interface { + CreateOLT(oltDTO req.OLTDTO) error + GetAllOLTs() ([]res.OLTResponse, error) + GetOLTByID(id uuid.UUID) (res.OLTDetailResponse, error) + UpdateOLT(id uuid.UUID, updateDTO req.UpdateOLTDTO) error + DeleteOLT(id uuid.UUID) error + AssignDeviceToOLT(oltID, deviceID uuid.UUID) error + UnassignDeviceFromOLT(deviceID uuid.UUID) error + GetDevicesByOLT(oltID uuid.UUID) ([]res.OLTDeviceResponse, error) +} + +type oltUsecase struct { + oltRepo repository.OLTRepo + deviceRepo repository.DeviceDetailsRepo + validate *validator.Validate + geocoder service.GeocodingService +} + +func NewOLTUsecase(oltRepo repository.OLTRepo, deviceRepo repository.DeviceDetailsRepo, geocoder service.GeocodingService) OLTUsecase { + return &oltUsecase{ + oltRepo: oltRepo, + deviceRepo: deviceRepo, + validate: validator.New(), + geocoder: geocoder, + } +} + +func (u *oltUsecase) CreateOLT(oltDTO req.OLTDTO) error { + if err := u.validate.Struct(oltDTO); err != nil { + return fmt.Errorf("validation error: %w", err) + } + + // Check if OLT name already exists + existingOLT, err := u.oltRepo.GetByName(oltDTO.OLTName) + if err == nil && existingOLT.ID != uuid.Nil { + return errors.New("OLT name already exists") + } + + olt := entity.OLT{ + ID: uuid.New(), + OLTName: oltDTO.OLTName, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + return u.oltRepo.Create(olt) +} + +func (u *oltUsecase) GetAllOLTs() ([]res.OLTResponse, error) { + olts, err := u.oltRepo.GetAll() + if err != nil { + return nil, err + } + + var responses []res.OLTResponse + for _, olt := range olts { + response := res.OLTResponse{ + ID: olt.ID, + OLTName: olt.OLTName, + CreatedAt: olt.CreatedAt, + UpdatedAt: olt.UpdatedAt, + } + responses = append(responses, response) + } + + return responses, nil +} + +func (u *oltUsecase) GetOLTByID(id uuid.UUID) (res.OLTDetailResponse, error) { + // First, check if OLT exists using the basic GetByID method + olt, err := u.oltRepo.GetByID(id) + if err != nil { + log.Printf("OLT not found: %v", err) + return res.OLTDetailResponse{}, errors.New("OLT not found") + } + + log.Printf("OLT found: %+v", olt) + + // Try to get OLT with devices, but fallback to basic info if it fails + oltWithDevices, err := u.oltRepo.GetByIDWithDevices(id) + if err != nil { + log.Printf("Error getting OLT with devices, using basic OLT info: %v", err) + // Use the basic OLT info we already retrieved + oltWithDevices = olt + oltWithDevices.Devices = []entity.Device{} // Ensure devices is empty slice, not nil + } + + log.Printf("OLT retrieved with devices: %+v", oltWithDevices) + + // Convert devices to device details responses + var deviceResponses []res.DeviceDetailsResponse + for _, device := range oltWithDevices.Devices { + log.Printf("Processing device: %s", device.DeviceCode) + + // Get the device details using the existing method which properly loads DevicePort + deviceDetails, err := u.deviceRepo.GetByID(device.ID) + if err != nil { + log.Printf("Error getting device details for %s: %v", device.DeviceCode, err) + continue + } + + deviceResponse, err := helper.ConvertToDeviceDetailsResponse(deviceDetails, u.geocoder) + if err != nil { + log.Printf("Error converting device response for %s: %v", device.DeviceCode, err) + continue + } + deviceResponses = append(deviceResponses, deviceResponse) + } + + // Initialize empty slice if nil to ensure JSON response shows empty array instead of null + if deviceResponses == nil { + deviceResponses = []res.DeviceDetailsResponse{} + } + + response := res.OLTDetailResponse{ + ID: oltWithDevices.ID, + OLTName: oltWithDevices.OLTName, + DeviceCount: len(deviceResponses), + Devices: deviceResponses, + CreatedAt: oltWithDevices.CreatedAt, + UpdatedAt: oltWithDevices.UpdatedAt, + } + + return response, nil +} + +func (u *oltUsecase) UpdateOLT(id uuid.UUID, updateDTO req.UpdateOLTDTO) error { + if err := u.validate.Struct(updateDTO); err != nil { + return fmt.Errorf("validation error: %w", err) + } + + // Check if OLT exists + _, err := u.oltRepo.GetByID(id) + if err != nil { + return errors.New("OLT not found") + } + + // Check if new name already exists (if name is being updated) + if updateDTO.OLTName != nil { + existingOLT, err := u.oltRepo.GetByName(*updateDTO.OLTName) + if err == nil && existingOLT.ID != id { + return errors.New("OLT name already exists") + } + } + + return u.oltRepo.Update(id, updateDTO) +} + +func (u *oltUsecase) DeleteOLT(id uuid.UUID) error { + // Check if OLT exists + _, err := u.oltRepo.GetByID(id) + if err != nil { + return errors.New("OLT not found") + } + + return u.oltRepo.Delete(id) +} + +func (u *oltUsecase) AssignDeviceToOLT(oltID, deviceID uuid.UUID) error { + return u.oltRepo.AssignDeviceToOLT(oltID, deviceID) +} + +func (u *oltUsecase) UnassignDeviceFromOLT(deviceID uuid.UUID) error { + return u.oltRepo.UnassignDeviceFromOLT(deviceID) +} + +func (u *oltUsecase) GetDevicesByOLT(oltID uuid.UUID) ([]res.OLTDeviceResponse, error) { + // Check if OLT exists + _, err := u.oltRepo.GetByID(oltID) + if err != nil { + return nil, errors.New("OLT not found") + } + + devices, err := u.oltRepo.GetDevicesByOLTID(oltID) + if err != nil { + return nil, err + } + + var responses []res.OLTDeviceResponse + for _, device := range devices { + // Get address using geocoder + address := "" + if u.geocoder != nil { + addr, err := u.geocoder.GetAddressFromCoordinates(device.Latitude, device.Longitude) + if err == nil { + address = addr + } + } + + // Get port usage from device port repository + portUsed := 0 + portUsageInfo, _, err := u.deviceRepo.GetPortUsageByDevice(device.ID) + if err == nil { + portUsed = portUsageInfo + } + + response := res.OLTDeviceResponse{ + ID: device.ID, + DeviceCode: device.DeviceCode, + DeviceType: string(device.DeviceType), + Address: address, + Province: device.Province, + City: device.City, + District: device.District, + Status: string(device.Status), + PortAmount: device.PortAmount, + PortUsed: portUsed, + ImageURL: device.ImageURL, + CreatedAt: device.CreatedAt, + UpdatedAt: device.UpdatedAt, + } + responses = append(responses, response) + } + + return responses, nil +} \ No newline at end of file diff --git a/usecase/tower_usecase.go b/usecase/tower_usecase.go index 11b0b8c..1b4e449 100644 --- a/usecase/tower_usecase.go +++ b/usecase/tower_usecase.go @@ -2,76 +2,100 @@ package usecase import ( "errors" + "fmt" + "mime/multipart" "time" "users_management/m/model/dto/req" + "users_management/m/model/dto/res" "users_management/m/model/entity" "users_management/m/repository" + "users_management/m/utils/helper" + "users_management/m/utils/service" "github.com/go-playground/validator/v10" "github.com/google/uuid" ) type TowerUseCase interface { - Post(tower req.TowerDTO) error - GetAll() ([]entity.Tower, error) - GetByID(id uuid.UUID) (entity.Tower, error) - UpdateTower(id uuid.UUID, tower req.UpdateTowerDTO) error + Post(tower req.TowerDTO, imageFile *multipart.FileHeader) error + 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 { towerRepo repository.TowerRepo validate *validator.Validate + geocoder service.GeocodingService } -func NewTowerUseCase(towerRepo repository.TowerRepo) TowerUseCase { +func NewTowerUseCase(towerRepo repository.TowerRepo, geocoder service.GeocodingService) TowerUseCase { return &towerUsecase{ towerRepo: towerRepo, + geocoder: geocoder, validate: validator.New(), } } - -func (u *towerUsecase) Post(tower req.TowerDTO) error { - err := u.validate.Struct(tower) - if err != nil { - return err - } - - newTower := entity.Tower{ - ID: uuid.New(), - DeviceID: tower.DeviceID, - TowerCode: tower.TowerCode, - Longitude: tower.Longitude, - Latitude: tower.Latitude, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - return u.towerRepo.Post(newTower) -} - -func (u *towerUsecase) GetAll() ([]entity.Tower, error) { - towers, err := u.towerRepo.GetAll() - if err != nil { - return towers, err - } - return towers, nil -} - - -func (u *towerUsecase) GetByID(id uuid.UUID) (entity.Tower, error) { - tower, err := u.towerRepo.GetByID(id) - if err != nil { - return tower, err - } - return tower, nil -} - -func (u *towerUsecase) UpdateTower(id uuid.UUID, tower req.UpdateTowerDTO) error { - err := u.validate.Struct(tower) +func (u *towerUsecase) PostWithMultipleImages(tower req.TowerDTO, imageFiles []*multipart.FileHeader) error { + err := u.validate.Struct(tower) if err != nil { - return err // Return validation error + return err } - updates := make(map[string]interface{}) + + + // 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 @@ -85,6 +109,50 @@ func (u *towerUsecase) UpdateTower(id uuid.UUID, tower req.UpdateTowerDTO) error 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 { @@ -94,4 +162,183 @@ func (u *towerUsecase) UpdateTower(id uuid.UUID, tower req.UpdateTowerDTO) error updates["UpdatedAt"] = time.Now() return u.towerRepo.Update(id, updates) -} \ No newline at end of file +} + + +func (u *towerUsecase) Post(tower req.TowerDTO, imageFile *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 imageURL string + if imageFile != nil { + imageURL, err = helper.SaveTowerImage(imageFile) + if err != nil { + return err + } + } + + newTower := entity.Tower{ + ID: uuid.New(), + DeviceID: tower.DeviceID, // Now nullable + TowerCode: tower.TowerCode, + Longitude: tower.Longitude, + Latitude: tower.Latitude, + ImageURL: imageURL, + ExternalTower: tower.ExternalTower, // Now nullable + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + return u.towerRepo.Post(newTower) +} + +func (u *towerUsecase) GetAll() ([]res.TowerResponse, error) { + towers, err := u.towerRepo.GetAll() + if err != nil { + return nil, err + } + towerResp, err := helper.ConvertToTowerResponses(towers,u.geocoder) + if err != nil { + return nil, err + } + return towerResp, nil +} + + +func (u *towerUsecase) GetByID(id uuid.UUID) (res.TowerResponse, error) { + tower, err := u.towerRepo.GetByID(id) + if err != nil { + return res.TowerResponse{}, err + } + + towerResp, err := helper.ConvertToTowerIDResponses(tower,u.geocoder) + if err != nil { + return res.TowerResponse{}, err + } + 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 { + return err // Return validation error + } + updates := make(map[string]interface{}) + + if tower.DeviceID != nil { + updates["DeviceID"] = *tower.DeviceID + } + if tower.TowerCode != nil { + updates["TowerCode"] = *tower.TowerCode + } + + // IMPORTANT: Ignore longitude/latitude updates if tower is assigned to a device + currentTower, err := u.towerRepo.GetByID(id) + if err != nil { + return err + } + + // If tower has a device assigned, ignore location updates + if currentTower.DeviceID == nil { + // Only allow location updates for external towers (not assigned to devices) + if tower.Longitude != nil { + updates["Longitude"] = *tower.Longitude + } + if tower.Latitude != nil { + updates["Latitude"] = *tower.Latitude + } + } + // If DeviceID is being assigned, sync location with device + if tower.DeviceID != nil && currentTower.DeviceID == nil { + // Get device location using repository method + longitude, latitude, err := u.towerRepo.GetDeviceLocation(*tower.DeviceID) + if err != nil { + return fmt.Errorf("failed to get device location: %w", err) + } + updates["Longitude"] = longitude + updates["Latitude"] = latitude + } + + if tower.ExternalTower != nil { + updates["ExternalTower"] = *tower.ExternalTower + } + + // Handle image upload + if imageFile != nil { + // Get current tower to delete old image + if currentTower.ImageURL != "" { + // Delete old image + helper.DeleteTowerImage(currentTower.ImageURL) + } + + // Save new image + imageURL, err := helper.SaveTowerImage(imageFile) + if err != nil { + return err + } + updates["ImageURL"] = imageURL + } + + // 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) +} diff --git a/usecase/users_usecase.go b/usecase/users_usecase.go index 19ec93f..aa58fca 100644 --- a/usecase/users_usecase.go +++ b/usecase/users_usecase.go @@ -1,30 +1,213 @@ package usecase import ( + "errors" + "time" + "users_management/m/model/dto" "users_management/m/model/entity" "users_management/m/repository" + "users_management/m/utils" + "users_management/m/utils/validation" + "github.com/go-playground/validator/v10" "github.com/google/uuid" ) type UsersUsecase interface { - GetRoleByDepartment(departmentName string) (uuid.UUID, error) - GetUserByUsername(username string) (entity.User, error) + GetRoleByDepartment(departmentName string) (uuid.UUID, error) + GetUserByUsername(username string) (entity.User, error) + GetUserByNomorInduk(nomorInduk string) (entity.User, error) + CreateUserFromExternal(nomorInduk, name, roleName string) error + UpdateUserRole(nomorInduk, roleName string) error + GetAllUsers() ([]entity.User, error) + RegisterUser(registerDTO dto.UserRegisterDTO) error + GetPendingUsers() ([]dto.PendingUserResponse, error) + ApproveUser(userID uuid.UUID) error + RejectUser(userID uuid.UUID) error + GetUserByUsernameWithStatus(username string) (entity.User, error) + CreateSuperAdminBypass(user entity.User) error + UpdateUserRoleByID(userID uuid.UUID, newRoleName string) error + UpdateUserRoleByUsername(username, newRoleName string) error + GetUserByID(userID uuid.UUID) (entity.User, error) + GetUserByName(name string) (entity.User, error) + } type usersUsecase struct { - userRepo repository.UsersRepo + userRepo repository.UsersRepo + validate *validator.Validate } func NewUsersUsecase(userRepo repository.UsersRepo) UsersUsecase { - return &usersUsecase{ - userRepo: userRepo, - } + + validate := validator.New() + validation.RegisterCustomValidators(validate) + return &usersUsecase{ + userRepo: userRepo, + validate: validate, + } +} + +func (u *usersUsecase) GetUserByName(name string) (entity.User, error) { + return u.userRepo.GetUserByName(name) } -func (u *usersUsecase) GetRoleByDepartment(departmentName string) (uuid.UUID, error) { +func (u *usersUsecase) CreateSuperAdminBypass(user entity.User) error { + return u.userRepo.CreateSuperAdminBypass(user) +} +// Add these implementations to the usersUsecase struct +func (u *usersUsecase) UpdateUserRoleByID(userID uuid.UUID, newRoleName string) error { + // Get the new role ID + newRoleID, err := u.GetRoleByDepartment(newRoleName) + if err != nil { + return errors.New("invalid role name: " + err.Error()) + } + + // Check if user exists + _, err = u.userRepo.GetUserByID(userID) + if err != nil { + return errors.New("user not found") + } + + // Update user role + return u.userRepo.UpdateUserRoleByID(userID, newRoleID) +} + +func (u *usersUsecase) UpdateUserRoleByUsername(username, newRoleName string) error { + // Get the new role ID + newRoleID, err := u.GetRoleByDepartment(newRoleName) + if err != nil { + return errors.New("invalid role name: " + err.Error()) + } + + // Check if user exists + _, err = u.userRepo.GetUserByUsernameWithStatus(username) + if err != nil { + return errors.New("user not found") + } + + // Update user role by username + return u.userRepo.UpdateUserRoleByUsername(username, newRoleID) +} + +func (u *usersUsecase) GetUserByID(userID uuid.UUID) (entity.User, error) { + return u.userRepo.GetUserByID(userID) +} + +func (u *usersUsecase) RegisterUser(registerDTO dto.UserRegisterDTO) error { + // Validate input + if err := u.validate.Struct(registerDTO); err != nil { + return err + } + // Check if username already exists + existingUser, err := u.userRepo.GetUserByUsernameWithStatus(registerDTO.Username) + if err == nil && existingUser.ID != uuid.Nil { + return errors.New("username already exists") + } + + // Check if nomor_induk already exists + if registerDTO.NomorInduk != "" { + existingUser, err := u.userRepo.GetUserByNomorInduk(registerDTO.NomorInduk) + if err == nil && existingUser.ID != uuid.Nil { + return errors.New("nomor induk already exists") + } + } + + existingUserByName, err := u.userRepo.GetUserByName(registerDTO.Name) + if err == nil && existingUserByName.ID != uuid.Nil { + return errors.New("name already exists, please use a different name") + } + + // Hash password + hashedPassword, err := utils.HashPassword(registerDTO.Password) + if err != nil { + return err + } + + teknisiRoleID, err := u.GetRoleByDepartment("Teknisi") + if err != nil { + return errors.New("failed to get Teknisi role: " + err.Error()) + } + + + // Create user with pending status + user := entity.User{ + ID: uuid.New(), + Name: registerDTO.Name, + Username: registerDTO.Username, + Password: hashedPassword, + NomorInduk: ®isterDTO.NomorInduk, + RoleID: teknisiRoleID, + Status: entity.UserStatusPending, // Set status to pending + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + return u.userRepo.RegisterUser(user) +} + +func (u *usersUsecase) GetPendingUsers() ([]dto.PendingUserResponse, error) { + users, err := u.userRepo.GetPendingUsers() + if err != nil { + return nil, err + } + + + var responses []dto.PendingUserResponse + for _, user := range users { + response := dto.PendingUserResponse{ + ID: user.ID, + Name: user.Name, + Username: user.Username, + NomorInduk: user.NomorInduk, + RoleName: user.Role.Name, + Status: dto.UserStatus(user.Status), + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + responses = append(responses, response) + } + + + + return responses, nil +} + +func (u *usersUsecase) ApproveUser(userID uuid.UUID) error { + // Check if user exists and is pending + user, err := u.userRepo.GetUserByID(userID) + if err != nil { + return errors.New("user not found") + } + + if user.Status != entity.UserStatusPending { + return errors.New("user is not in pending status") + } + + return u.userRepo.UpdateUserStatus(userID, entity.UserStatusApproved) +} + +func (u *usersUsecase) RejectUser(userID uuid.UUID) error { + // Check if user exists and is pending + user, err := u.userRepo.GetUserByID(userID) + if err != nil { + return errors.New("user not found") + } + + if user.Status != entity.UserStatusPending { + return errors.New("user is not in pending status") + } + + return u.userRepo.UpdateUserStatus(userID, entity.UserStatusRejected) +} + +func (u *usersUsecase) GetUserByUsernameWithStatus(username string) (entity.User, error) { + return u.userRepo.GetUserByUsernameWithStatus(username) +} + +func (u *usersUsecase) GetRoleByDepartment(departmentName string) (uuid.UUID, error) { role, err := u.userRepo.GetRoleByDepartment(departmentName) if err != nil { return uuid.Nil, err @@ -33,10 +216,49 @@ func (u *usersUsecase) GetRoleByDepartment(departmentName string) (uuid.UUID, er } func (u *usersUsecase) GetUserByUsername(username string) (entity.User, error) { - users, err := u.userRepo.GetUserByUsername(username) - if err != nil { - return users, err - } - return users, nil + users, err := u.userRepo.GetUserByUsername(username) + if err != nil { + return users, err + } + return users, nil } +// New methods for RBAC +func (u *usersUsecase) GetUserByNomorInduk(nomorInduk string) (entity.User, error) { + return u.userRepo.GetUserByNomorInduk(nomorInduk) +} + +func (u *usersUsecase) CreateUserFromExternal(nomorInduk, name, roleName string) error { + // Get role ID + roleID, err := u.GetRoleByDepartment(roleName) + if err != nil { + return err + } + + // Create user + user := entity.User{ + ID: uuid.New(), + NomorInduk: &nomorInduk, + RoleID: roleID, + Name: name, + Username: nomorInduk, // Use nomor_induk as username + Password: "", // External auth, no local password needed + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + return u.userRepo.CreateUserFromExternal(user) +} + +func (u *usersUsecase) UpdateUserRole(nomorInduk, roleName string) error { + roleID, err := u.GetRoleByDepartment(roleName) + if err != nil { + return err + } + + return u.userRepo.UpdateUserRole(nomorInduk, roleID) +} + +func (u *usersUsecase) GetAllUsers() ([]entity.User, error) { + return u.userRepo.GetAllUsers() +} \ No newline at end of file diff --git a/utils/helper/activity_logs_helper.go b/utils/helper/activity_logs_helper.go new file mode 100644 index 0000000..66f0d50 --- /dev/null +++ b/utils/helper/activity_logs_helper.go @@ -0,0 +1,84 @@ +package helper + +import ( + "users_management/m/model/dto/res" + "users_management/m/model/entity" +) + +// ConvertToActivityLogResponses converts activity log entities to simple response format for users +func ConvertToActivityLogResponses(logs []entity.ActivityLog) []res.ActivityLogResponse { + var responses []res.ActivityLogResponse + + for _, log := range logs { + response := res.ActivityLogResponse{ + ID: log.ID, + Username: log.User.Username, + Role: log.User.Role.Name, + Action: log.Action, + Resource: log.Resource, + IPAddress: log.IPAddress, + UserAgent: log.UserAgent, + Timestamp: log.Timestamp, + } + responses = append(responses, response) + } + + return responses +} + +// ConvertToActivityLogDetailResponses converts activity log entities to detailed response format for admins +func ConvertToActivityLogDetailResponses(logs []entity.ActivityLog) []res.ActivityLogDetailResponse { + var responses []res.ActivityLogDetailResponse + + for _, log := range logs { + response := res.ActivityLogDetailResponse{ + ID: log.ID, + UserID: log.UserID, + Username: log.User.Username, + Role: log.User.Role.Name, + Action: log.Action, + Resource: log.Resource, + ResourceID: log.ResourceID, + OldData: log.OldData, + NewData: log.NewData, + IPAddress: log.IPAddress, + UserAgent: log.UserAgent, + Timestamp: log.Timestamp, + } + responses = append(responses, response) + } + + return responses +} + +// ConvertToSingleActivityLogResponse converts single activity log entity to simple response +func ConvertToSingleActivityLogResponse(log entity.ActivityLog) res.ActivityLogResponse { + return res.ActivityLogResponse{ + ID: log.ID, + Username: log.User.Username, + Role: log.User.Role.Name, + Action: log.Action, + Resource: log.Resource, + IPAddress: log.IPAddress, + UserAgent: log.UserAgent, + Timestamp: log.Timestamp, + } +} + +// ConvertToSingleActivityLogDetailResponse converts single activity log entity to detailed response +func ConvertToSingleActivityLogDetailResponse(log entity.ActivityLog) res.ActivityLogDetailResponse { + return res.ActivityLogDetailResponse{ + ID: log.ID, + UserID: log.UserID, + Username: log.User.Username, + Role: log.User.Role.Name, + Action: log.Action, + Resource: log.Resource, + ResourceID: log.ResourceID, + OldData: log.OldData, + NewData: log.NewData, + IPAddress: log.IPAddress, + UserAgent: log.UserAgent, + Timestamp: log.Timestamp, + } +} \ No newline at end of file diff --git a/utils/helper/backboneHelperRes.go b/utils/helper/backboneHelperRes.go new file mode 100644 index 0000000..3d15861 --- /dev/null +++ b/utils/helper/backboneHelperRes.go @@ -0,0 +1,49 @@ +package helper + +import ( + "users_management/m/model/dto/res" + "users_management/m/model/entity" + + "github.com/google/uuid" +) + +func ConvertToBackboneResponses(backbone []entity.Backbone, totalFishbone map[uuid.UUID]int) ([]res.BackboneResponse, error) { + var responses []res.BackboneResponse + for _, backbone := range backbone { + count := 0 + if fishboneCount, exist := totalFishbone[backbone.ID]; exist { + count = fishboneCount + } + backboneResp := res.BackboneResponse { + ID: backbone.ID, + BackboneCode: backbone.BackboneCode, + DevStart: backbone.DeviceStart.DeviceCode, + DevEnd: backbone.DeviceEnd.DeviceCode, + DeviceStartID: backbone.DeviceStart.ID, + DeviceEndID: backbone.DeviceEnd.ID, + CoreAmount: backbone.CoreAmount, + TotalFishbone: count, + CreatedAt: backbone.CreatedAt, + UpdatedAt: backbone.UpdatedAt, + } + responses = append(responses, backboneResp) + } + + return responses, nil +} + +func ConvertToBackboneRespId(backbone entity.Backbone, fishboneCount int) (res.BackboneResponse, error) { + backboneResp := res.BackboneResponse { + ID: backbone.ID, + DevStart: backbone.DeviceStart.DeviceCode, + DevEnd: backbone.DeviceEnd.DeviceCode, + DeviceStartID: backbone.DeviceStart.ID, + DeviceEndID: backbone.DeviceEnd.ID, + CoreAmount: backbone.CoreAmount, + TotalFishbone: fishboneCount, + CreatedAt: backbone.CreatedAt, + UpdatedAt: backbone.UpdatedAt, + } + + return backboneResp, nil +} \ No newline at end of file diff --git a/utils/helper/deviceHelperRes.go b/utils/helper/deviceHelperRes.go new file mode 100644 index 0000000..283e647 --- /dev/null +++ b/utils/helper/deviceHelperRes.go @@ -0,0 +1,108 @@ +package helper + +import ( + "log" + "users_management/m/model/dto/res" + "users_management/m/model/entity" + "users_management/m/utils/service" +) + +func ConvertToDeviceTypeResponse(devices []entity.Device) []res.DeviceTypeResponse { + var responses []res.DeviceTypeResponse + for _, devices := range devices { + deviceResp := res.DeviceTypeResponse{ + ID: devices.ID, + DeviceType: string(devices.DeviceType), + DeviceCode: devices.DeviceCode, + + } + responses = append(responses, deviceResp) + } + + return responses +} + + +func ConvertToDeviceResponse(devices []entity.Device, geocoder service.GeocodingService) ([]res.DeviceResponse, error) { + var responses []res.DeviceResponse + + for _, device := range devices { + 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{ + ID: device.ID, + DeviceCode: device.DeviceCode, + DeviceType: string(device.DeviceType), + Longitude: device.Longitude, + Latitude: device.Latitude, + PortAmount: device.PortAmount, + 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, + } + + responses = append(responses, response) + } + + return responses, nil +} +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") + } + + // 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: 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 +} \ No newline at end of file diff --git a/utils/helper/device_details.go b/utils/helper/device_details.go new file mode 100644 index 0000000..77d31d6 --- /dev/null +++ b/utils/helper/device_details.go @@ -0,0 +1,242 @@ +package helper + +import ( + "fmt" + "log" + "users_management/m/model/dto/res" + "users_management/m/model/entity" + "users_management/m/utils/service" +) + +func ConvertToDeviceDetailsResponses(devices []entity.DeviceDetails, geocoder service.GeocodingService) ([]res.DeviceDetailsResponse, error) { + var responses []res.DeviceDetailsResponse + + for _, device := range devices { + response, err := ConvertToDeviceDetailsResponse(device, geocoder) + if err != nil { + return nil, err + } + responses = append(responses, response) + } + + return responses, nil +} + +func ConvertToDeviceDetailsResponse(device entity.DeviceDetails, geocoder service.GeocodingService) (res.DeviceDetailsResponse, error) { + // Get address + address := "" + if geocoder != nil { + addr, err := geocoder.GetAddressFromCoordinates(device.Latitude, device.Longitude) + if err != nil { + log.Printf("Geocoding error for device %s: %v", device.DeviceCode, err) + address = fmt.Sprintf("Coordinates: %.6f, %.6f", device.Latitude, device.Longitude) + } else { + address = addr + } + } + + // Get all backbones connected to this device + allBackbones := device.GetAllBackbones() + backboneInfos := make([]res.BackboneConnectionInfo, 0) + for _, backbone := range allBackbones { + isStartDevice := backbone.DeviceStartID == device.ID + connectedTo := "" + + if isStartDevice && backbone.DeviceEnd.DeviceCode != "" { + connectedTo = backbone.DeviceEnd.DeviceCode + } else if !isStartDevice && backbone.DeviceStart.DeviceCode != "" { + connectedTo = backbone.DeviceStart.DeviceCode + } + + info := res.BackboneConnectionInfo{ + ID: backbone.ID, + BackboneCode: backbone.BackboneCode, + CoreAmount: backbone.CoreAmount, + IsStartDevice: isStartDevice, + ConnectedTo: connectedTo, + } + backboneInfos = append(backboneInfos, info) + } + + // Get all fishbones connected to this device + allFishbones := device.GetAllFishbones() + fishboneInfos := make([]res.FishboneConnectionInfo, 0) + for _, fishbone := range allFishbones { + isStartDevice := fishbone.DeviceStartID == device.ID + connectedTo := "" + + if isStartDevice && fishbone.DeviceEnd.DeviceCode != "" { + connectedTo = fishbone.DeviceEnd.DeviceCode + } else if !isStartDevice && fishbone.DeviceStart.DeviceCode != "" { + connectedTo = fishbone.DeviceStart.DeviceCode + } + + info := res.FishboneConnectionInfo{ + ID: fishbone.ID, + FishboneCode: fishbone.FishboneCode, + CoreAmount: fishbone.CoreAmount, + BackboneCode: fishbone.Backbone.BackboneCode, + IsStartDevice: isStartDevice, + ConnectedTo: connectedTo, + } + fishboneInfos = append(fishboneInfos, info) + } + + // COMBINE ALL TOWERS INTO A SINGLE ARRAY + towerInfos := make([]res.TowerConnectionDetail, 0) + + // First, add the assigned tower (tower referenced by device.TowerID) + if device.TowerID != nil && device.Tower != nil { + distance := calculateDistance(device.Latitude, device.Longitude, device.Tower.Latitude, device.Tower.Longitude) + + var externalTower *bool + if device.Tower.ExternalTower != nil { + externalTower = device.Tower.ExternalTower + } + + // Get all tower image URLs + allTowerImageURLs := device.Tower.GetAllImageURLs() + + // Primary image URL + var imageURL *string + if device.Tower.ImageURL != "" { + imageURL = &device.Tower.ImageURL + } + + assignedTowerInfo := res.TowerConnectionDetail{ + ID: device.Tower.ID, + TowerCode: device.Tower.TowerCode, + Distance: distance, + ExternalTower: externalTower, + ImageURL: imageURL, + ImageURLs: allTowerImageURLs, + } + towerInfos = append(towerInfos, assignedTowerInfo) + } + + // Then, add towers that reference this device via dev_id (but avoid duplicates) + for _, tower := range device.Towers { + // Skip if this tower is already added as assigned tower + if device.TowerID != nil && tower.ID == *device.TowerID { + continue + } + + distance := calculateDistance(device.Latitude, device.Longitude, tower.Latitude, tower.Longitude) + + var externalTower *bool + if tower.ExternalTower != nil { + externalTower = tower.ExternalTower + } + + // Get all tower image URLs + allTowerImageURLs := tower.GetAllImageURLs() + + // Primary image URL + var imageURL *string + if tower.ImageURL != "" { + imageURL = &tower.ImageURL + } + + info := res.TowerConnectionDetail{ + ID: tower.ID, + TowerCode: tower.TowerCode, + Distance: distance, + ExternalTower: externalTower, + ImageURL: imageURL, + ImageURLs: allTowerImageURLs, + } + towerInfos = append(towerInfos, info) + } + + // Convert port assignments - only for ODP devices + var finalPortAssignments []res.PortAssignmentResponse + var customerNames []string + + if device.DeviceType == "ODP" { + // Only ODP devices have port assignments and customers + entityPortAssignments := device.DevicePort.GetPortAssignmentsWithDetails() + + // Ensure we show all ports up to PortAmount + portAssignmentMap := make(map[int]res.PortAssignmentResponse) + for _, pa := range entityPortAssignments { + portAssignmentMap[pa.PortNumber] = res.PortAssignmentResponse{ + PortNumber: pa.PortNumber, + CustomerName: pa.CustomerName, + IsOccupied: pa.IsOccupied, + Status: pa.Status, + Bandwidth: pa.Bandwidth, + } + } + + // Fill in missing ports + finalPortAssignments = make([]res.PortAssignmentResponse, 0) + for i := 1; i <= device.PortAmount; i++ { + if assignment, exists := portAssignmentMap[i]; exists { + finalPortAssignments = append(finalPortAssignments, assignment) + } else { + finalPortAssignments = append(finalPortAssignments, res.PortAssignmentResponse{ + PortNumber: i, + CustomerName: nil, + IsOccupied: false, + Status: entity.PortStatusOff, + Bandwidth: nil, + }) + } + } + + // Get customer names for ODP devices + customerNames = device.DevicePort.GetCustomerNamesWithPorts() + } else { + // OTB and other device types don't have customers or detailed port assignments + finalPortAssignments = []res.PortAssignmentResponse{} + customerNames = []string{} + } + + // Get all image URLs + allImageURLs := device.GetAllImageURLs() + + // Handle empty slice vs nil for JSON response + if len(allImageURLs) == 0 { + allImageURLs = []string{} // Return empty array instead of nil + } + // Handle OLT information - create nested object structure + var oltInfo *res.OLTInfo + if device.OLTID != nil && device.OLT != nil && device.OLT.OLTName != "" { + oltInfo = &res.OLTInfo{ + OLTID: *device.OLTID, + OLTName: device.OLT.OLTName, + } + } + + response := res.DeviceDetailsResponse{ + ID: device.ID, + DeviceCode: device.DeviceCode, + DeviceType: string(device.DeviceType), + Address: address, + Longitude: device.Longitude, + Latitude: device.Latitude, + Status: string(device.Status), + PortAmount: device.PortAmount, + PortUsed: device.DevicePort.PortUsed, + PortAvailable: device.DevicePort.PortAvailable, + CustomerNames: customerNames, // Empty for OTB + PortAssignments: finalPortAssignments, // Empty for OTB + Province: device.Province, + City: device.City, + District: device.District, + ImageURL: device.ImageURL, + ImageURLs: allImageURLs, + TowerID: device.TowerID, // Keep TowerID for reference + // Remove AssignedTower field - now everything is in Towers + OLT: oltInfo, // Add OLT name + Backbones: backboneInfos, + Fishbones: fishboneInfos, + Towers: towerInfos, // All towers combined here + CreatedAt: device.CreatedAt, + UpdatedAt: device.UpdatedAt, + } + + return response, nil +} + + diff --git a/utils/helper/device_inspection.go b/utils/helper/device_inspection.go new file mode 100644 index 0000000..1690398 --- /dev/null +++ b/utils/helper/device_inspection.go @@ -0,0 +1,103 @@ +package helper + +import ( + "bytes" + "users_management/m/model/dto/res" + "users_management/m/model/entity" +) + +// IsEmptyUUID checks if a UUID is empty (all zeros) +func IsEmptyUUID(id [16]byte) bool { + return bytes.Equal(id[:], make([]byte, 16)) +} + +func ConvertToDeviceInspectionResponses(inspections []entity.DeviceInspection) []res.DeviceInspectionResponse { + var responses []res.DeviceInspectionResponse + + for _, inspection := range inspections { + response := res.DeviceInspectionResponse{ + ID: inspection.ID, + DeviceCode: inspection.Device.DeviceCode, + DeviceType: string(inspection.Device.DeviceType), + UserName: inspection.User.Name, + Status: inspection.Status, + PortUsed: inspection.PortUsed, + PortAvailable: inspection.PortAvailable, + CableAmount: inspection.CableAmount, + Description: inspection.Description, + ImageURL: inspection.ImageURL, + InspectionPlacement: inspection.InspectionPlacement, // Full address + Longitude: inspection.Longitude, + Latitude: inspection.Latitude, + InspectionApproval: inspection.InspectionApproval, + CreatedAt: inspection.CreatedAt, + UpdatedAt: inspection.UpdatedAt, + } + + if inspection.Backbone != nil && !IsEmptyUUID(inspection.Backbone.ID) { + response.BackboneCode = &inspection.Backbone.BackboneCode + } + if inspection.Fishbone != nil && !IsEmptyUUID(inspection.Fishbone.ID) { + response.FishboneCode = &inspection.Fishbone.FishboneCode + } + if inspection.Tower != nil && !IsEmptyUUID(inspection.Tower.ID) { + response.TowerCode = &inspection.Tower.TowerCode + } + + responses = append(responses, response) + } + + return responses +} + + +func ConvertToDeviceInspectionDetailResponse(inspection entity.DeviceInspection) res.DeviceInspectionDetailResponse { + response := res.DeviceInspectionDetailResponse{ + ID: inspection.ID, + Device: res.DeviceInfo{ + ID: inspection.Device.ID, + DeviceCode: inspection.Device.DeviceCode, + DeviceType: string(inspection.Device.DeviceType), + }, + User: res.UserInfo{ + ID: inspection.User.ID, + Name: inspection.User.Name, + Username: inspection.User.Username, + }, + Status: inspection.Status, + PortUsed: inspection.PortUsed, + PortAvailable: inspection.PortAvailable, + CableAmount: inspection.CableAmount, + Description: inspection.Description, + ImageURL: inspection.ImageURL, + InspectionPlacement: inspection.InspectionPlacement, // Full address + Longitude: inspection.Longitude, + Latitude: inspection.Latitude, + InspectionApproval: inspection.InspectionApproval, + CreatedAt: inspection.CreatedAt, + UpdatedAt: inspection.UpdatedAt, + } + + if inspection.Backbone != nil && !IsEmptyUUID(inspection.Backbone.ID) { + response.Backbone = &res.BackboneInfo{ + ID: inspection.Backbone.ID, + BackboneCode: inspection.Backbone.BackboneCode, + } + } + + if inspection.Fishbone != nil && !IsEmptyUUID(inspection.Fishbone.ID) { + response.Fishbone = &res.FishboneInfo{ + ID: inspection.Fishbone.ID, + FishboneCode: inspection.Fishbone.FishboneCode, + } + } + + if inspection.Tower != nil && !IsEmptyUUID(inspection.Tower.ID) { + response.Tower = &res.TowerInfo{ + ID: inspection.Tower.ID, + TowerCode: inspection.Tower.TowerCode, + } + } + + return response +} \ No newline at end of file diff --git a/utils/helper/fishboneHelperRes.go b/utils/helper/fishboneHelperRes.go new file mode 100644 index 0000000..21f1dab --- /dev/null +++ b/utils/helper/fishboneHelperRes.go @@ -0,0 +1,87 @@ +package helper + +import ( + "users_management/m/model/dto/res" + "users_management/m/model/entity" + + "github.com/google/uuid" +) + +// ConvertToFishboneResponses converts fishbone entities to response DTOs +func ConvertToFishboneResponses(fishbones []entity.Fishbone, totalFishbone map[uuid.UUID]int) ([]res.FishboneResponse, error) { + var responses []res.FishboneResponse + + for _, fishbone := range fishbones { + fishboneResp := res.FishboneResponse{ + ID: fishbone.ID, + FishboneCode: fishbone.FishboneCode, + BackboneCode: fishbone.Backbone.BackboneCode, + DeviceStart: fishbone.DeviceStart.DeviceCode, + DeviceEnd: fishbone.DeviceEnd.DeviceCode, + DeviceStartID: fishbone.DeviceStart.ID, + DeviceEndID: fishbone.DeviceEnd.ID, + CoreAmount: fishbone.CoreAmount, + CreatedAt: fishbone.CreatedAt, + UpdatedAt: fishbone.UpdatedAt, + } + responses = append(responses, fishboneResp) + } + + return responses, nil +} + +// ConvertToFishboneDetailResponse converts a single fishbone entity to detailed response +func ConvertToFishboneDetailResponse(fishbone entity.Fishbone) res.FishboneDetailResponse { + return res.FishboneDetailResponse{ + ID: fishbone.ID, + FishboneCode: fishbone.FishboneCode, + DeviceStartID: fishbone.DeviceStart.ID, + DeviceEndID: fishbone.DeviceEnd.ID, + BackboneCode: fishbone.Backbone.BackboneCode, + CoreAmount: fishbone.CoreAmount, + CreatedAt: fishbone.CreatedAt, + UpdatedAt: fishbone.UpdatedAt, + } +} + +// ConvertToSimpleFishboneResponses converts fishbone entities to simple response format +func ConvertToSimpleFishboneResponses(fishbones []entity.Fishbone) []res.FishboneResponse { + var responses []res.FishboneResponse + + for _, fishbone := range fishbones { + fishboneResp := res.FishboneResponse{ + ID: fishbone.ID, + FishboneCode: fishbone.FishboneCode, + BackboneCode: fishbone.Backbone.BackboneCode, + DeviceStart: fishbone.DeviceStart.DeviceCode, + DeviceEnd: fishbone.DeviceEnd.DeviceCode, + DeviceStartID: fishbone.DeviceStart.ID, + DeviceEndID: fishbone.DeviceEnd.ID, + CoreAmount: fishbone.CoreAmount, + CreatedAt: fishbone.CreatedAt, + UpdatedAt: fishbone.UpdatedAt, + } + responses = append(responses, fishboneResp) + } + + return responses +} + +// GetFishboneStats calculates statistics for fishbones +func GetFishboneStats(fishbones []entity.Fishbone) map[string]interface{} { + totalFishbones := len(fishbones) + totalCores := 0 + backboneCount := make(map[uuid.UUID]int) + + for _, fishbone := range fishbones { + totalCores += fishbone.CoreAmount + backboneCount[fishbone.BackboneID]++ + } + + return map[string]interface{}{ + "total_cores": totalCores, + "average_cores": float64(totalCores) / float64(totalFishbones), + "unique_backbones": len(backboneCount), + "backbone_usage": backboneCount, + } +} \ No newline at end of file diff --git a/utils/helper/image_uploader.go b/utils/helper/image_uploader.go new file mode 100644 index 0000000..58bf893 --- /dev/null +++ b/utils/helper/image_uploader.go @@ -0,0 +1,226 @@ +package helper + +import ( + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + MaxFileSize = 5 << 20 // 5MB + 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 + 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") + } + + // Create upload directory if it doesn't exist + 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(TowerUploadDir, filename) + + // 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 URL (adjust based on your server setup) + return fmt.Sprintf("/uploads/towers/%s", filename), nil +} + +// DeleteTowerImage deletes an image file +func DeleteTowerImage(imageURL string) error { + if imageURL == "" { + return nil + } + + // Extract filename from URL + filename := filepath.Base(imageURL) + filePath := filepath.Join(TowerUploadDir, 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) +} \ No newline at end of file diff --git a/utils/helper/nearest_device_helper.go b/utils/helper/nearest_device_helper.go new file mode 100644 index 0000000..4e57947 --- /dev/null +++ b/utils/helper/nearest_device_helper.go @@ -0,0 +1,220 @@ +package helper + +import ( + "fmt" + "log" + "math" + "users_management/m/model/dto/res" + "users_management/m/model/entity" + "users_management/m/repository" + "users_management/m/utils/service" + + "github.com/google/uuid" +) + +func ConvertToNearestDeviceResponses(devices []entity.DeviceWithDistance, repo repository.NearestDeviceRepo, geocoder service.GeocodingService) ([]res.NearestDeviceResponse, error) { + var responses []res.NearestDeviceResponse + + for _, device := range devices { + // Get address + address := "" + if geocoder != nil { + addr, err := geocoder.GetAddressFromCoordinates(device.Latitude, device.Longitude) + if err != nil { + log.Printf("Geocoding error for device %s: %v", device.DeviceCode, err) + address = fmt.Sprintf("Coordinates: %.6f, %.6f", device.Latitude, device.Longitude) + } else { + address = addr + } + } + + // Count connections + backboneCount, fishboneCount, towerCount, err := repo.CountConnectionsByDeviceID(device.ID) + if err != nil { + log.Printf("Error counting connections for device %s: %v", device.DeviceCode, err) + backboneCount, fishboneCount, towerCount = 0, 0, 0 + } + + response := res.NearestDeviceResponse{ + ID: device.ID, + DeviceCode: device.DeviceCode, + DeviceType: string(device.DeviceType), + Distance: device.Distance, // Use the calculated distance from query + Address: address, + Longitude: device.Longitude, + Latitude: device.Latitude, + Status: string(device.Status), + PortAmount: device.PortAmount, + Region: device.Region, + Province: device.Province, + City: device.City, + District: device.District, + ImageURL: device.ImageURL, + BackboneCount: backboneCount, + FishboneCount: fishboneCount, + TowerCount: towerCount, + CreatedAt: device.CreatedAt, + } + + responses = append(responses, response) + } + + return responses, nil +} + +func ConvertToNearestDeviceDetailResponse(device entity.Device, distance float64, repo repository.NearestDeviceRepo, geocoder service.GeocodingService) (res.NearestDeviceDetailResponse, error) { + // Get address + address := "" + if geocoder != nil { + addr, err := geocoder.GetAddressFromCoordinates(device.Latitude, device.Longitude) + if err != nil { + log.Printf("Geocoding error for device %s: %v", device.DeviceCode, err) + address = fmt.Sprintf("Coordinates: %.6f, %.6f", device.Latitude, device.Longitude) + } else { + address = addr + } + } + + // Get detailed connection information + backbones, err := repo.GetBackbonesByDeviceID(device.ID) + if err != nil { + return res.NearestDeviceDetailResponse{}, err + } + + fishbones, err := repo.GetFishbonesByDeviceID(device.ID) + if err != nil { + return res.NearestDeviceDetailResponse{}, err + } + + towers, err := repo.GetTowersByDeviceID(device.ID) + if err != nil { + return res.NearestDeviceDetailResponse{}, err + } + + // Convert to response format + backboneInfos := convertToBackboneConnectionInfos(backbones, device.ID) + fishboneInfos := convertToFishboneConnectionInfos(fishbones, device.ID) + towerInfos := convertToTowerConnectionInfos(towers, device.Latitude, device.Longitude) + + allImageURLs := device.GetAllImageURLs() + if len(allImageURLs) == 0 { + allImageURLs = []string{} + } + + log.Printf("Device %s has %d images", device.DeviceCode, len(allImageURLs)) + + response := res.NearestDeviceDetailResponse{ + ID: device.ID, + DeviceCode: device.DeviceCode, + DeviceType: string(device.DeviceType), + Distance: distance, + Address: address, + Longitude: device.Longitude, + Latitude: device.Latitude, + Status: string(device.Status), + PortAmount: device.PortAmount, + Province: device.Province, + City: device.City, + District: device.District, + ImageURLs: device.GetAllImageURLs(), + Backbones: backboneInfos, + Fishbones: fishboneInfos, + Towers: towerInfos, + CreatedAt: device.CreatedAt, + UpdatedAt: device.UpdatedAt, + } + + return response, nil +} + +func convertToBackboneConnectionInfos(backbones []entity.Backbone, deviceID uuid.UUID) []res.BackboneConnectionInfo { + var infos []res.BackboneConnectionInfo + + for _, backbone := range backbones { + isStartDevice := backbone.DeviceStartID == deviceID + connectedTo := "" + + if isStartDevice && backbone.DeviceEnd.DeviceCode != "" { + connectedTo = backbone.DeviceEnd.DeviceCode + } else if !isStartDevice && backbone.DeviceStart.DeviceCode != "" { + connectedTo = backbone.DeviceStart.DeviceCode + } + + info := res.BackboneConnectionInfo{ + ID: backbone.ID, + BackboneCode: backbone.BackboneCode, + CoreAmount: backbone.CoreAmount, + IsStartDevice: isStartDevice, + ConnectedTo: connectedTo, + } + + infos = append(infos, info) + } + + return infos +} + +func convertToFishboneConnectionInfos(fishbones []entity.Fishbone, deviceID uuid.UUID) []res.FishboneConnectionInfo { + var infos []res.FishboneConnectionInfo + + for _, fishbone := range fishbones { + isStartDevice := fishbone.DeviceStartID == deviceID + connectedTo := "" + + if isStartDevice && fishbone.DeviceEnd.DeviceCode != "" { + connectedTo = fishbone.DeviceEnd.DeviceCode + } else if !isStartDevice && fishbone.DeviceStart.DeviceCode != "" { + connectedTo = fishbone.DeviceStart.DeviceCode + } + + info := res.FishboneConnectionInfo{ + ID: fishbone.ID, + FishboneCode: fishbone.FishboneCode, + CoreAmount: fishbone.CoreAmount, + BackboneCode: fishbone.Backbone.BackboneCode, + IsStartDevice: isStartDevice, + ConnectedTo: connectedTo, + } + + infos = append(infos, info) + } + + return infos +} + +func convertToTowerConnectionInfos(towers []entity.Tower, deviceLat, deviceLng float64) []res.TowerConnectionInfo { + var infos []res.TowerConnectionInfo + + for _, tower := range towers { + // Calculate distance from device to tower + distance := calculateDistance(deviceLat, deviceLng, tower.Latitude, tower.Longitude) + + info := res.TowerConnectionInfo{ + ID: tower.ID, + TowerCode: tower.TowerCode, + Distance: distance, + ImageURL: &tower.ImageURL, + } + + infos = append(infos, info) + } + + return infos +} + +func calculateDistance(lat1, lng1, lat2, lng2 float64) float64 { + const earthRadius = 6371 // Earth's radius in kilometers + + lat1Rad := lat1 * math.Pi / 180 + lng1Rad := lng1 * math.Pi / 180 + lat2Rad := lat2 * math.Pi / 180 + lng2Rad := lng2 * math.Pi / 180 + + dlat := lat2Rad - lat1Rad + dlng := lng2Rad - lng1Rad + + a := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dlng/2)*math.Sin(dlng/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + return earthRadius * c +} \ No newline at end of file diff --git a/utils/helper/nearest_towers.go b/utils/helper/nearest_towers.go new file mode 100644 index 0000000..46f1720 --- /dev/null +++ b/utils/helper/nearest_towers.go @@ -0,0 +1,114 @@ +package helper + +import ( + "fmt" + "log" + "users_management/m/model/dto/res" + "users_management/m/model/entity" + "users_management/m/repository" + "users_management/m/utils/service" + + "github.com/google/uuid" +) + +func ConvertToNearestTowerResponses(towers []entity.TowerWithDistance, geocoder service.GeocodingService) ([]res.NearestTowerResponse, error) { + var responses []res.NearestTowerResponse + + for _, tower := range towers { + // Get address + address := "" + if geocoder != nil { + addr, err := geocoder.GetAddressFromCoordinates(tower.Latitude, tower.Longitude) + if err != nil { + log.Printf("Geocoding error for tower %s: %v", tower.TowerCode, err) + address = fmt.Sprintf("Coordinates: %.6f, %.6f", tower.Latitude, tower.Longitude) + } else { + address = addr + } + } + + response := res.NearestTowerResponse{ + ID: tower.ID, + TowerCode: tower.TowerCode, + Distance: tower.Distance, + Address: address, + Longitude: tower.Longitude, + Latitude: tower.Latitude, + ImageURL: tower.ImageURL, + ExternalTower: tower.ExternalTower, + DeviceCode: tower.DeviceCode, + Province: tower.Province, + City: tower.City, + District: tower.District, + CreatedAt: tower.CreatedAt, + } + + responses = append(responses, response) + } + + return responses, nil +} + +func ConvertToNearestTowerDetailResponse(tower entity.Tower, distance float64, repo repository.NearestDeviceRepo, geocoder service.GeocodingService) (res.NearestTowerDetailResponse, error) { + // Get address + address := "" + if geocoder != nil { + addr, err := geocoder.GetAddressFromCoordinates(tower.Latitude, tower.Longitude) + if err != nil { + log.Printf("Geocoding error for tower %s: %v", tower.TowerCode, err) + address = fmt.Sprintf("Coordinates: %.6f, %.6f", tower.Latitude, tower.Longitude) + } else { + address = addr + } + } + + // Get all image URLs + allImageURLs := tower.GetAllImageURLs() + + var deviceInfo *res.DeviceConnectionInfo + if tower.Device.ID != uuid.Nil { + // Get device address + deviceAddress := "" + if geocoder != nil { + addr, err := geocoder.GetAddressFromCoordinates(tower.Device.Latitude, tower.Device.Longitude) + if err != nil { + log.Printf("Geocoding error for device %s: %v", tower.Device.DeviceCode, err) + deviceAddress = fmt.Sprintf("Coordinates: %.6f, %.6f", tower.Device.Latitude, tower.Device.Longitude) + } else { + deviceAddress = addr + } + } + + // Calculate distance from tower to device + deviceDistance := calculateDistance(tower.Latitude, tower.Longitude, tower.Device.Latitude, tower.Device.Longitude) + + deviceInfo = &res.DeviceConnectionInfo{ + ID: tower.Device.ID, + DeviceCode: tower.Device.DeviceCode, + DeviceType: string(tower.Device.DeviceType), + Distance: deviceDistance, + Address: deviceAddress, + Longitude: tower.Device.Longitude, + Latitude: tower.Device.Latitude, + Status: string(tower.Device.Status), + PortAmount: tower.Device.PortAmount, + } + } + + response := res.NearestTowerDetailResponse{ + ID: tower.ID, + TowerCode: tower.TowerCode, + Distance: distance, + Address: address, + Longitude: tower.Longitude, + Latitude: tower.Latitude, + ImageURL: &tower.ImageURL, + ImageURLs: allImageURLs, + ExternalTower: tower.ExternalTower, + Device: deviceInfo, + CreatedAt: tower.CreatedAt, + UpdatedAt: tower.UpdatedAt, + } + + return response, nil +} \ No newline at end of file diff --git a/utils/helper/towerHelperRes.go b/utils/helper/towerHelperRes.go new file mode 100644 index 0000000..fde3648 --- /dev/null +++ b/utils/helper/towerHelperRes.go @@ -0,0 +1,82 @@ +package helper + +import ( + "log" + "users_management/m/model/dto/res" + "users_management/m/model/entity" + "users_management/m/utils/service" +) + +func ConvertToTowerResponses(towers []entity.Tower, geocoder service.GeocodingService) ([]res.TowerResponse, error) { + var responses []res.TowerResponse + + for _, tower := range towers { + var address string + allImageURLs := tower.GetAllImageURLs() + + if geocoder != nil { + generatedAddress, err := geocoder.GetAddressFromCoordinates(tower.Latitude, tower.Longitude) + if err != nil { + log.Printf("Geocoding error for tower %s: %v", tower.TowerCode, err) + } else { + address = generatedAddress + } + } + + var deviceCode *string + if tower.Device.DeviceCode != "" { + deviceCode = &tower.Device.DeviceCode + } + + towerResp := res.TowerResponse{ + ID: tower.ID, + DeviceCode: deviceCode, // Now nullable + TowerCode: &tower.TowerCode, + Longitude: tower.Longitude, + Latitude: tower.Latitude, + Address: address, + ImageURL: tower.ImageURL, + ImageURLs: allImageURLs, // All images + ExternalTower: tower.ExternalTower, // Now nullable + CreatedAt: tower.CreatedAt, + UpdatedAt: tower.UpdatedAt, + } + responses = append(responses, towerResp) + } + return responses, nil +} + +func ConvertToTowerIDResponses(tower entity.Tower, geocoder service.GeocodingService) (res.TowerResponse, error) { + var address string + + if geocoder != nil { + generatedAddress, err := geocoder.GetAddressFromCoordinates(tower.Latitude, tower.Longitude) + if err != nil { + log.Printf("Geocoding error for tower %s: %v", tower.TowerCode, err) + } else { + address = generatedAddress + } + } + + var deviceCode *string + if tower.Device.DeviceCode != "" { + deviceCode = &tower.Device.DeviceCode + } + // Get all image URLs + allImageURLs := tower.GetAllImageURLs() + + towerResp := res.TowerResponse{ + ID: tower.ID, + DeviceCode: deviceCode, // Now nullable + TowerCode: &tower.TowerCode, + Longitude: tower.Longitude, + Latitude: tower.Latitude, + Address: address, + ImageURL: tower.ImageURL, + ImageURLs: allImageURLs, // All images + ExternalTower: tower.ExternalTower, // Now nullable + CreatedAt: tower.CreatedAt, + UpdatedAt: tower.UpdatedAt, + } + return towerResp, nil +} \ No newline at end of file diff --git a/utils/insecure_http.go b/utils/insecure_http.go new file mode 100644 index 0000000..65858b2 --- /dev/null +++ b/utils/insecure_http.go @@ -0,0 +1,34 @@ +package utils + +import ( + "crypto/tls" + "net/http" + "time" +) + +// CreateInsecureHTTPClient creates an HTTP client that skips SSL verification +func CreateInsecureHTTPClient() *http.Client { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + + return &http.Client{ + Transport: tr, + Timeout: 30 * time.Second, + } +} + +// CreateSecureHTTPClient creates a standard HTTP client with SSL verification +func CreateSecureHTTPClient() *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + } +} + +// GetHTTPClient returns appropriate client based on environment +func GetHTTPClient(skipSSL bool) *http.Client { + if skipSSL { + return CreateInsecureHTTPClient() + } + return CreateSecureHTTPClient() +} \ No newline at end of file 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 diff --git a/utils/service/geoCache_service.go b/utils/service/geoCache_service.go new file mode 100644 index 0000000..ae103f2 --- /dev/null +++ b/utils/service/geoCache_service.go @@ -0,0 +1,78 @@ +package service + +import ( + "fmt" + "log" + "sync" + "sync/atomic" +) + +type CacheStats struct { + Hits int64 + Misses int64 + Size int +} + +type CachedGeocoder struct { + underlying GeocodingService + cache map[string]string + mutex sync.RWMutex + hits int64 + misses int64 +} + +func NewCachedGeocodingService(underlying GeocodingService) *CachedGeocoder { + return &CachedGeocoder{ + underlying: underlying, + cache: make(map[string]string), + } +} + +func (g *CachedGeocoder) GetAddressFromCoordinates(latitude, longitude float64) (string, error) { + cacheKey := fmt.Sprintf("%.6f,%.6f", latitude, longitude) + + // Check cache first + g.mutex.RLock() + if address, ok := g.cache[cacheKey]; ok { + g.mutex.RUnlock() + atomic.AddInt64(&g.hits, 1) + log.Printf("CACHE HIT: Coordinates (%.6f,%.6f) found in cache", latitude, longitude) + return address, nil + } + g.mutex.RUnlock() + + atomic.AddInt64(&g.misses, 1) + + // Not in cache, call the underlying service + address, err := g.underlying.GetAddressFromCoordinates(latitude, longitude) + if err != nil { + return "", err + } + + // Cache the result + g.mutex.Lock() + g.cache[cacheKey] = address + g.mutex.Unlock() + log.Printf("CACHE UPDATE: Coordinates (%.6f,%.6f) added to cache", latitude, longitude) + + return address, nil +} + +// GetStats returns the current cache statistics +func (g *CachedGeocoder) GetStats() CacheStats { + g.mutex.RLock() + stats := CacheStats{ + Hits: g.hits, + Misses: g.misses, + Size: len(g.cache), + } + g.mutex.RUnlock() + return stats +} + +// ClearCache empties the cache +func (g *CachedGeocoder) ClearCache() { + g.mutex.Lock() + g.cache = make(map[string]string) + g.mutex.Unlock() +} diff --git a/utils/service/geo_service.go b/utils/service/geo_service.go new file mode 100644 index 0000000..2ac7952 --- /dev/null +++ b/utils/service/geo_service.go @@ -0,0 +1,92 @@ +package service + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "time" +) + +type GeocodingService interface { + GetAddressFromCoordinates(latitude, longitude float64) (string, error) +} + +type nominatimGeocoder struct { + client *http.Client +} + + +func NewGeocodingService() GeocodingService { + client := &http.Client{ + Timeout: 10 * time.Second, + } + return &nominatimGeocoder{ + client: client, + } +} + +type NominatimResponse struct { + DisplayName string `json:"display_name"` + Error string `json:"error"` +} + + +func (g *nominatimGeocoder) GetAddressFromCoordinates(latitude, longitude float64) (string, error) { + + + if latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 { + errMsg := fmt.Sprintf("Invalid coordinates: lat=%f, lon=%f", latitude, longitude) + return "", fmt.Errorf(errMsg) + } + + url := fmt.Sprintf( + "https://nominatim.openstreetmap.org/reverse?format=json&lat=%f&lon=%f&zoom=18&addressdetails=1", + latitude, longitude, + ) + + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + log.Printf("Request creation error: %v", err) + return "", err + } + req.Header.Set("User-Agent", "AssetManagementApp") + + resp, err := g.client.Do(req) + if err != nil { + log.Printf("HTTP request error: %v", err) + return "", err + } + defer resp.Body.Close() + + // Read full response body + bodyBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Printf("Error reading response body: %v", err) + return "", err + } + + + var result NominatimResponse + if err := json.Unmarshal(bodyBytes, &result); err != nil { + log.Printf("JSON unmarshal error: %v", err) + return "", err + } + + // Detailed error checking + if result.Error != "" { + log.Printf("Nominatim API Error: %s", result.Error) + return "", fmt.Errorf("geocoding error: %s", result.Error) + } + + // Check for empty display name + if result.DisplayName == "" { + log.Printf("No address found for coordinates: %f, %f", latitude, longitude) + return "", fmt.Errorf("no address found for these coordinates") + } + + return result.DisplayName, nil +} + diff --git a/utils/utils.go b/utils/utils.go index b20dca9..100ac9e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,6 +1,12 @@ package utils -import "golang.org/x/crypto/bcrypt" +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v4" + "golang.org/x/crypto/bcrypt" +) func HashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) @@ -14,4 +20,47 @@ func HashPassword(password string) (string, error) { func CheckPasswordHash(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil +} + +var jwtSecret = []byte("your-secret-key-change-this-in-production") // Change this in production + +type Claims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func GenerateJWT(userID, username, role string) (string, error) { + claims := Claims{ + UserID: userID, + Username: username, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // Token expires in 24 hours + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +func ValidateJWT(tokenString string) (string, string, error) { + claims := &Claims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + + if err != nil { + return "", "", err + } + + if !token.Valid { + return "", "", errors.New("invalid token") + } + + return claims.UserID, claims.Username, nil } \ No newline at end of file diff --git a/utils/validation/validation_ports.go b/utils/validation/validation_ports.go new file mode 100644 index 0000000..9eece5d --- /dev/null +++ b/utils/validation/validation_ports.go @@ -0,0 +1,22 @@ +package validation + +import ( + "users_management/m/model/entity" + "github.com/go-playground/validator/v10" +) + +// RegisterPortValidators registers port-related validation rules +func RegisterPortValidators(validate *validator.Validate) { + validate.RegisterValidation("portstatus", validatePortStatus) +} + +// validatePortStatus checks if the value is a valid PortStatus +func validatePortStatus(fl validator.FieldLevel) bool { + value := fl.Field().String() + switch entity.PortStatus(value) { + case entity.PortStatusOn, entity.PortStatusOff, entity.PortStatusDyingGasp, entity.PortStatusLOS: + return true + default: + return false + } +} \ No newline at end of file diff --git a/utils/validation/validation_username.go b/utils/validation/validation_username.go new file mode 100644 index 0000000..88b3d44 --- /dev/null +++ b/utils/validation/validation_username.go @@ -0,0 +1,50 @@ +package validation + +import ( + "regexp" + "strings" + "unicode" + + "github.com/go-playground/validator/v10" +) + +// RegisterCustomValidators registers custom validation rules +func RegisterCustomValidators(validate *validator.Validate) { + validate.RegisterValidation("nospace", validateNoSpace) + validate.RegisterValidation("alphanumunderscore", validateAlphaNumUnderscore) + validate.RegisterValidation("alphaspace", validateAlphaSpace) // Add this + validate.RegisterValidation("alphanum", validateAlphaNum) // Add this + + RegisterPortValidators(validate) // Ensure port validators are registered +} + +// validateNoSpace checks that the field contains no spaces +func validateNoSpace(fl validator.FieldLevel) bool { + value := fl.Field().String() + return !strings.Contains(value, " ") +} + +// validateAlphaNumUnderscore allows alphanumeric characters and underscores only +func validateAlphaNumUnderscore(fl validator.FieldLevel) bool { + value := fl.Field().String() + matched, _ := regexp.MatchString("^[a-zA-Z0-9_]+$", value) + return matched +} + +// validateAlphaSpace allows alphabetic characters and spaces only (for names) +func validateAlphaSpace(fl validator.FieldLevel) bool { + value := fl.Field().String() + for _, char := range value { + if !unicode.IsLetter(char) && char != ' ' { + return false + } + } + return true +} + +// validateAlphaNum allows alphanumeric characters only +func validateAlphaNum(fl validator.FieldLevel) bool { + value := fl.Field().String() + matched, _ := regexp.MatchString("^[a-zA-Z0-9]+$", value) + return matched +} \ No newline at end of file