From 6e66afad736904a3ecfddd199f52bb23ca30e8c3 Mon Sep 17 00:00:00 2001 From: maxogod Date: Wed, 11 Jun 2025 01:59:02 -0300 Subject: [PATCH 1/4] feat: mangement handler to search users, block/unblock or delete (as admin) --- config/config.go | 44 ++-- internal/dto/management_dto.go | 8 + internal/dto/user_response.go | 11 +- internal/handlers/users/auth/handler.go | 3 - internal/handlers/users/auth/login.go | 4 - internal/handlers/users/management/handler.go | 15 ++ .../handlers/users/management/management.go | 146 +++++++++++++ .../users/management/management_test.go | 202 ++++++++++++++++++ internal/repository/memory/users.go | 33 +++ internal/repository/users/postgres.go | 36 ++++ internal/repository/users/postgres_test.go | 133 ++++++++++++ internal/repository/users/repository.go | 9 + internal/services/users/auth/login.go | 7 - internal/services/users/auth/service.go | 4 - .../services/users/management/management.go | 48 +++++ .../users/management/management_test.go | 91 ++++++++ internal/services/users/management/service.go | 22 ++ 17 files changed, 778 insertions(+), 38 deletions(-) create mode 100644 internal/dto/management_dto.go create mode 100644 internal/handlers/users/management/handler.go create mode 100644 internal/handlers/users/management/management.go create mode 100644 internal/handlers/users/management/management_test.go create mode 100644 internal/services/users/management/management.go create mode 100644 internal/services/users/management/management_test.go create mode 100644 internal/services/users/management/service.go diff --git a/config/config.go b/config/config.go index 52b9ee0..7a90a66 100644 --- a/config/config.go +++ b/config/config.go @@ -15,9 +15,12 @@ const ( type Config struct { // Server configuration - SV_ADDR string - SV_PORT string - SV_ENVIRONMENT ServerEnvironment + SV_ADDR string + SV_PORT string + SV_ENVIRONMENT ServerEnvironment + MAX_LOGIN_RETRIES int + BLOCK_DURATION time.Duration + PAGE_SIZE int // Auth JWT_SECRET []byte @@ -29,9 +32,6 @@ type Config struct { SUPABASE_KEY string MAX_FILE_UPLOAD_SIZE int64 - MAX_LOGIN_RETRIES int - BLOCK_DURATION time.Duration - // Notifications service NOTIFICATIONS_SERVICE string @@ -45,9 +45,12 @@ type Config struct { // It retrieves values from environment variables or uses default values. func LoadConfig() *Config { return &Config{ - SV_ADDR: getEnvOrDefault("ADDR", "0.0.0.0"), - SV_PORT: getEnvOrDefault("PORT", "8080"), - SV_ENVIRONMENT: getServerEnvironment(), + SV_ADDR: getEnvOrDefault("ADDR", "0.0.0.0"), + SV_PORT: getEnvOrDefault("PORT", "8080"), + SV_ENVIRONMENT: getServerEnvironment(), + MAX_LOGIN_RETRIES: getEnvAsInt("MAX_LOGIN_RETRIES", 3), + BLOCK_DURATION: time.Second * time.Duration(getEnvAsInt("BLOCK_DURATION", 15)), + PAGE_SIZE: getEnvAsInt("PAGE_SIZE", 10), JWT_SECRET: []byte(getEnvOrDefault("JWT_SECRET", "default_secret_key")), GOOGLE_CLIENT_ID: getEnvOrDefault("GOOGLE_CLIENT_ID", ""), @@ -55,10 +58,7 @@ func LoadConfig() *Config { DB_URL: getEnvOrDefault("DB_URL", ""), STORAGE_URL: getEnvOrDefault("STORAGE_URL", ""), SUPABASE_KEY: getEnvOrDefault("SUPABASE_KEY", ""), - MAX_FILE_UPLOAD_SIZE: getMaxFileSizeOrDefault("MAX_FILE_SIZE", 10*1024*1024), // 10MB - - MAX_LOGIN_RETRIES: 5, - BLOCK_DURATION: time.Second * 10, + MAX_FILE_UPLOAD_SIZE: getEnvAsInt64("MAX_FILE_SIZE", 10*1024*1024), // 10MB NOTIFICATIONS_SERVICE: "http://" + getEnvOrDefault("NOTIFICATIONS_SERVICE", "") + ":8000", @@ -88,9 +88,8 @@ func getEnvOrDefault(key, defaultValue string) string { return value } -// getMaxFileSizeOrDefault retrieves the maximum file size in bytes -// from the environment variable. -func getMaxFileSizeOrDefault(key string, defaultValue int64) int64 { +// getEnvAsInt64 retrieves environment variable as an int64. +func getEnvAsInt64(key string, defaultValue int64) int64 { value := os.Getenv(key) if value == "" { return defaultValue @@ -101,3 +100,16 @@ func getMaxFileSizeOrDefault(key string, defaultValue int64) int64 { } return size } + +// getEnvAsInt retrieves the value of an environment variable as an integer. +func getEnvAsInt(key string, defaultValue int) int { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + intValue, err := strconv.Atoi(value) + if err != nil { + return defaultValue + } + return intValue +} diff --git a/internal/dto/management_dto.go b/internal/dto/management_dto.go new file mode 100644 index 0000000..eaf764c --- /dev/null +++ b/internal/dto/management_dto.go @@ -0,0 +1,8 @@ +package dto + +import "time" + +type UpdateUserBlockStatusDTO struct { + Block bool `json:"block" binding:"required"` + BlockUntil *time.Time `json:"block_until"` +} diff --git a/internal/dto/user_response.go b/internal/dto/user_response.go index 1f4fc06..dadbc06 100644 --- a/internal/dto/user_response.go +++ b/internal/dto/user_response.go @@ -1,14 +1,17 @@ package dto import ( + "time" + "github.com/google/uuid" ) // UserResponseDTO represents the structure to be used // when returning a user object in an http reponse. type UserResponseDTO struct { - ID uuid.UUID `json:"id" binding:"required"` - Name string `json:"name" binding:"required"` - Email string `json:"email" binding:"required,email"` - Active bool `json:"active" binding:"required"` + ID uuid.UUID `json:"id" binding:"required"` + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Active bool `json:"active" binding:"required"` + BlockedUntil *time.Time `json:"blocked_until,omitempty"` } diff --git a/internal/handlers/users/auth/handler.go b/internal/handlers/users/auth/handler.go index 44614b1..da619de 100644 --- a/internal/handlers/users/auth/handler.go +++ b/internal/handlers/users/auth/handler.go @@ -21,7 +21,4 @@ type AuthHandler interface { // RecoverPassword handles the password change process for a user or token refresh. RecoverPassword(c *gin.Context) - - // UpdateUserBlockStatus updates the user's block status based on the provided ID and block status. - UpdateUserBlockStatus(c *gin.Context) } diff --git a/internal/handlers/users/auth/login.go b/internal/handlers/users/auth/login.go index 0a88048..67e36c0 100644 --- a/internal/handlers/users/auth/login.go +++ b/internal/handlers/users/auth/login.go @@ -264,10 +264,6 @@ func (h *authHandler) RecoverPassword(c *gin.Context) { handlers.HandleBodilessResponse(c, http.StatusNoContent) } -func (h *authHandler) UpdateUserBlockStatus(c *gin.Context) { - // TODO: Implement the logic to update user block status by admins -} - /* Helper Functions */ // handleValidSession checks if the JWT token is valid and if the user session is valid. diff --git a/internal/handlers/users/management/handler.go b/internal/handlers/users/management/handler.go new file mode 100644 index 0000000..33d0766 --- /dev/null +++ b/internal/handlers/users/management/handler.go @@ -0,0 +1,15 @@ +package management + +import "github.com/gin-gonic/gin" + +// ManagementHandler is an interface that defines the methods for handling user management-related requests. +type ManagementHandler interface { + // UpdateUserBlockStatus updates the user's block status based on the provided ID and block status. + UpdateUserBlockStatus(c *gin.Context) // Protected + + // DeleteUser deletes a user based on the provided ID. + DeleteUser(c *gin.Context) // Protected + + // GetPlatformUsers fetches a list of users on the platform (and optionally filter them by name). + GetPlatformUsers(c *gin.Context) +} diff --git a/internal/handlers/users/management/management.go b/internal/handlers/users/management/management.go new file mode 100644 index 0000000..d0c2f2e --- /dev/null +++ b/internal/handlers/users/management/management.go @@ -0,0 +1,146 @@ +package management + +import ( + "net/http" + "strconv" + "time" + "users-microservice/config" + "users-microservice/internal/dto" + "users-microservice/internal/errors" + "users-microservice/internal/handlers" + "users-microservice/internal/services/users/management" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type managementHandler struct { + service management.ManagementService + conf *config.Config +} + +func NewManagementHandler(service management.ManagementService, conf *config.Config) ManagementHandler { + return &managementHandler{ + service: service, + conf: conf, + } +} + +// @Summary GetPlatformUsers +// @Description Fetch a list of users on the platform (and optionally filter them by name). +// @Tags users +// @Produce json +// @Param name_contains query string false "Filter users by name contains" +// @Param page query int false "Page number for pagination (default is 1)" +// @Success 200 {array} dto.UserResponseDTO "Successful retrieval of users (Wrapped in data envelope)" +// @Failure 500 {object} dto.ErrorResponse "Internal server error" +// @Router /management [get] +func (h *managementHandler) GetPlatformUsers(c *gin.Context) { + nameContains := c.Query("name_contains") + page, err := strconv.Atoi(c.DefaultQuery("page", "1")) + if err != nil || page < 1 { + _ = c.Error(errors.NewBadRequestError("Invalid page number")) + return + } + isAdmin := c.GetBool("isAdmin") + + users, err := h.service.GetPlatformUsers(nameContains, page) + if err != nil { + _ = c.Error(err) + return + } + + usersDTO := make([]dto.UserResponseDTO, len(users)) + for i, user := range users { + usersDTO[i] = dto.UserResponseDTO{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Active: user.Active, + } + if isAdmin { + usersDTO[i].BlockedUntil = h.service.GetBlockedUntil(user.ID) + } + } + + handlers.HandleSuccessResponse(c, http.StatusOK, users) +} + +// @Summary UpdateUserBlockStatus +// @Description Update the user's block status based on the provided ID and block status. +// @Tags users +// @Accept json +// @Produce json +// @Param id path string true "User ID" +// @Param body dto.UpdateUserBlockStatusDTO true "Update user block status data" +// @Success 204 {object} dto.UserResponseDTO "Successful update of user block status" +// @Failure 400 {object} dto.ErrorResponse "Bad request body" +// @Failure 403 {object} dto.ErrorResponse "Unauthorized access" +// @Failure 404 {object} dto.ErrorResponse "User not found" +// @Router /management/block_status/{id} [put] +func (h *managementHandler) UpdateUserBlockStatus(c *gin.Context) { + isAdmin := c.GetBool("isAdmin") + if !isAdmin { + _ = c.Error(errors.NewForbiddenError("You are not authorized to perform this action")) + return + } + + id := c.Param("id") + parsedID, err := uuid.Parse(id) + if err != nil { + _ = c.Error(errors.NewBadRequestError("missing or invalid user id")) + return + } + + var blockStatus dto.UpdateUserBlockStatusDTO + if err := c.ShouldBindJSON(&blockStatus); err != nil { + _ = c.Error(errors.NewBadRequestError("invalid request body")) + return + } else if blockStatus.Block && blockStatus.BlockUntil == nil { + _ = c.Error(errors.NewBadRequestError("blocked_until is required when block is true")) + return + } else if blockStatus.Block && blockStatus.BlockUntil.Before(time.Now()) { + _ = c.Error(errors.NewBadRequestError("blocked_until must be in the future")) + return + } + + err = h.service.UpdateUserBlockStatus(parsedID, blockStatus.BlockUntil, blockStatus.Block) + if err != nil { + _ = c.Error(err) + return + } + + handlers.HandleBodilessResponse(c, http.StatusNoContent) +} + +// @Summary DeleteUser +// @Description Delete a user based on the provided ID. +// @Tags users +// @Param id path string true "User ID" +// @Success 204 "User successfully deleted" +// @Failure 400 {object} dto.ErrorResponse "Bad request body" +// @Failure 403 {object} dto.ErrorResponse "Unauthorized access" +// @Failure 404 {object} dto.ErrorResponse "User not found" +// @Router /management/{id} [delete] +func (h *managementHandler) DeleteUser(c *gin.Context) { + isAdmin := c.GetBool("isAdmin") + if !isAdmin { + _ = c.Error(errors.NewForbiddenError("You are not authorized to perform this action")) + return + } + + id := c.Param("id") + parsedID, err := uuid.Parse(id) + if err != nil { + _ = c.Error(errors.NewBadRequestError("missing or invalid user id")) + return + } + + err = h.service.DeleteUser(parsedID) + if err != nil { + _ = c.Error(err) + return + } + + handlers.HandleBodilessResponse(c, http.StatusNoContent) +} diff --git a/internal/handlers/users/management/management_test.go b/internal/handlers/users/management/management_test.go new file mode 100644 index 0000000..169b51a --- /dev/null +++ b/internal/handlers/users/management/management_test.go @@ -0,0 +1,202 @@ +package management_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + "users-microservice/config" + "users-microservice/internal/dto" + "users-microservice/internal/handlers/users/management" + "users-microservice/internal/middleware" + "users-microservice/internal/models" + "users-microservice/internal/repository/memory" + "users-microservice/internal/repository/users" + managementService "users-microservice/internal/services/users/management" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/bcrypt" +) + +var router *gin.Engine +var repo users.UsersRepository +var managementHandler management.ManagementHandler +var userID = uuid.New() +var userIDForDelete = uuid.New() + +func TestMain(m *testing.M) { + repo = memory.NewInMemoryUsersRepository() + + // Create user + hashedPassword, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + if err != nil { + panic(err) + } + user := &models.User{ + Name: "Test User", + Email: "test@email.com", + Password: string(hashedPassword), + } + _ = repo.CreateUser(user) + userID = user.ID + + user = &models.User{ + Name: "Test User 2", + Email: "test2@email.com", + Password: string(hashedPassword), + } + _ = repo.CreateUser(user) + userIDForDelete = user.ID + + conf := &config.Config{} + service := managementService.NewManagementService(repo, conf) + managementHandler = management.NewManagementHandler(service, conf) + + // Mock router + gin.SetMode(gin.TestMode) + router = gin.Default() + router.Use(middleware.ErrorHandlerMiddleware(conf)) + router.PUT("/management/block_status/:id", managementHandler.UpdateUserBlockStatus) + router.DELETE("/management/:id", managementHandler.DeleteUser) + router.GET("/management", managementHandler.GetPlatformUsers) + + // Run the tests + m.Run() +} + +func TestGetPlatformUsers(t *testing.T) { + req, _ := http.NewRequest("GET", "/management", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response dto.DataResponse[[]dto.UserResponseDTO] + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + + assert.NotEmpty(t, response.Data) + assert.Greater(t, len(response.Data), 0) +} + +func TestGetPlatformUsersBadRequest(t *testing.T) { + req, _ := http.NewRequest("GET", "/management?page=invalid", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUpdateUserBlockStatus_Unauthorized(t *testing.T) { + until := time.Now().Add(24 * time.Hour) + block := dto.UpdateUserBlockStatusDTO{ + Block: true, + BlockUntil: &until, + } + body, _ := json.Marshal(block) + req, _ := http.NewRequest("PUT", "/management/block_status/123e4567-e89b-12d3-a456-426614174000", bytes.NewBuffer(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestUpdateUserBlockStatus_BadRequest(t *testing.T) { + r := createRouterWithAdmin(true) + r.PUT("/management/block_status/:id", managementHandler.UpdateUserBlockStatus) + + until := time.Now().Add(24 * time.Hour) + block := dto.UpdateUserBlockStatusDTO{ + Block: true, + BlockUntil: &until, + } + body, _ := json.Marshal(block) + + req, _ := http.NewRequest("PUT", "/management/block_status/invalid-id", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + req, _ = http.NewRequest("PUT", "/management/block_status/"+userID.String(), bytes.NewBuffer([]byte("invalid json"))) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + block.BlockUntil = nil + body, _ = json.Marshal(block) + req, _ = http.NewRequest("PUT", "/management/block_status/"+userID.String(), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + until = time.Now().Add(-1 * time.Hour) + block.BlockUntil = &until + body, _ = json.Marshal(block) + req, _ = http.NewRequest("PUT", "/management/block_status/"+userID.String(), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUpdateUserBlockStatus_Success(t *testing.T) { + until := time.Now().Add(24 * time.Hour) + block := dto.UpdateUserBlockStatusDTO{ + Block: true, + BlockUntil: &until, + } + body, _ := json.Marshal(block) + req, _ := http.NewRequest("PUT", "/management/block_status/"+userID.String(), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r := createRouterWithAdmin(true) + r.PUT("/management/block_status/:id", managementHandler.UpdateUserBlockStatus) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) +} + +func TestDeleteUser_Unauthorized(t *testing.T) { + req, _ := http.NewRequest("DELETE", "/management/123e4567-e89b-12d3-a456-426614174000", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestDeleteUser_BadRequest(t *testing.T) { + r := createRouterWithAdmin(true) + r.DELETE("/management/:id", managementHandler.DeleteUser) + + req, _ := http.NewRequest("DELETE", "/management/invalid-id", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestDeleteUser_Success(t *testing.T) { + r := createRouterWithAdmin(true) + r.DELETE("/management/:id", managementHandler.DeleteUser) + + req, _ := http.NewRequest("DELETE", "/management/"+userIDForDelete.String(), nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) +} + +/* HELPER FUNCTIONS */ + +func createRouterWithAdmin(admin bool) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.Default() + r.Use(middleware.ErrorHandlerMiddleware(nil)) + r.Use(func(c *gin.Context) { + c.Set("isAdmin", admin) + }) + return r +} diff --git a/internal/repository/memory/users.go b/internal/repository/memory/users.go index ff25764..393e127 100644 --- a/internal/repository/memory/users.go +++ b/internal/repository/memory/users.go @@ -51,6 +51,18 @@ func (r *inMemoryUsersRepository) CreateGoogleUser(user *models.User, _picture, return nil } +func (r *inMemoryUsersRepository) DeleteUser(id uuid.UUID) error { + if _, exists := r.usersDB[id]; !exists { + return errors.NewNotFoundError("user not found") + } + + delete(r.usersDB, id) + delete(r.googleSubsDB, id) + delete(r.blocksDB, id) + + return nil +} + func (r *inMemoryUsersRepository) MergeGoogleUser(userID uuid.UUID, name, picture, sub string) (*models.User, error) { user, exists := r.usersDB[userID] if !exists { @@ -68,6 +80,16 @@ func (r *inMemoryUsersRepository) MergeGoogleUser(userID uuid.UUID, name, pictur /* READ OPERATIONS */ +func (r *inMemoryUsersRepository) GetUsers(_namePrefix string, _page int) ([]models.User, error) { + // In a real implementation, filter by namePrefix and handle pagination. + var usersList []models.User + for _, user := range r.usersDB { + usersList = append(usersList, user) + } + + return usersList, nil +} + func (r *inMemoryUsersRepository) GetUserByID(id uuid.UUID) (*models.User, error) { user, exists := r.usersDB[id] if !exists { @@ -94,6 +116,17 @@ func (r *inMemoryUsersRepository) GetGoogleSubByUserID(id uuid.UUID) (string, er return sub, nil } +func (r *inMemoryUsersRepository) GetBlockStatus(id uuid.UUID) (*models.Block, error) { + blockedUntil, exists := r.blocksDB[id] + if !exists { + return nil, errors.NewNotFoundError("user not found") + } + return &models.Block{ + UserID: id, + BlockedUntil: blockedUntil, + }, nil +} + func (r *inMemoryUsersRepository) ActivateAccount(id uuid.UUID, token string) error { _, exists := r.usersDB[id] if !exists { diff --git a/internal/repository/users/postgres.go b/internal/repository/users/postgres.go index 43c7a8d..688a0a4 100644 --- a/internal/repository/users/postgres.go +++ b/internal/repository/users/postgres.go @@ -56,6 +56,16 @@ func (r *usersRepository) CreateUser(user *models.User) error { }) } +func (r *usersRepository) DeleteUser(id uuid.UUID) error { + if err := r.db.Where("id = ?", id).Delete(&models.User{}).Error; err != nil { + if stdErrors.Is(err, gorm.ErrRecordNotFound) { + return errors.NewNotFoundError("user not found") + } + return errors.NewInternalServerError("could not delete user: " + err.Error()) + } + return nil +} + func (r *usersRepository) CreateGoogleUser(user *models.User, picture, sub string) error { user.Active = true // Set user as active for Google auth return r.db.Transaction(func(tx *gorm.DB) error { @@ -127,6 +137,21 @@ func (r *usersRepository) MergeGoogleUser(userID uuid.UUID, name, picture, sub s /* READ OPERATIONS */ +func (r *usersRepository) GetUsers(nameContains string, page int) ([]models.User, error) { + users := make([]models.User, 0) + query := r.db.Model(&models.User{}) + + if nameContains != "" { + query = query.Where("name ILIKE ?", "%"+nameContains+"%") + } + + if err := query.Offset((page - 1) * r.conf.PAGE_SIZE).Limit(r.conf.PAGE_SIZE).Find(&users).Error; err != nil { + return nil, errors.NewInternalServerError("could not get users: " + err.Error()) + } + + return users, nil +} + func (r *usersRepository) GetUserByID(id uuid.UUID) (*models.User, error) { var user models.User if err := r.db.First(&user, id).Error; err != nil { @@ -160,6 +185,17 @@ func (r *usersRepository) GetGoogleSubByUserID(id uuid.UUID) (string, error) { return googleSub.Sub, nil } +func (r *usersRepository) GetBlockStatus(id uuid.UUID) (*models.Block, error) { + var block models.Block + if err := r.db.Where("user_id = ?", id).First(&block).Error; err != nil { + if stdErrors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.NewNotFoundError("block status not found") + } + return nil, errors.NewInternalServerError("could not get block status: " + err.Error()) + } + return &block, nil +} + func (r *usersRepository) ActivateAccount(id uuid.UUID, token string) error { var accountActivation models.AccountActivation if err := r.db.Where("user_id = ?", id).First(&accountActivation).Error; err != nil { diff --git a/internal/repository/users/postgres_test.go b/internal/repository/users/postgres_test.go index 5aa2cd1..0c57ffc 100644 --- a/internal/repository/users/postgres_test.go +++ b/internal/repository/users/postgres_test.go @@ -19,6 +19,7 @@ import ( var conf *config.Config = &config.Config{ NOTIFICATIONS_SERVICE: "some-url", + PAGE_SIZE: 10, } /* CREATE USER */ @@ -140,6 +141,50 @@ func TestCreateUser_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } +func TestDeleteUser_UserNotFound(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM "users"`). + WithArgs(userID). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectRollback() + + err := repo.DeleteUser(userID) + assert.Error(t, err) + + notFoundErr, ok := err.(*errors.NotFoundError) + assert.True(t, ok) + if ok { + assert.Equal(t, "user not found", notFoundErr.Message) + } + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDeleteUser_Success(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM "users"`). + WithArgs(userID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + err := repo.DeleteUser(userID) + assert.NoError(t, err) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + func TestCreateGoogleUser_Success(t *testing.T) { db, mock, cleanup := setupMockDB(t) defer cleanup() @@ -239,6 +284,48 @@ func TestMergeGoogleUser_Success(t *testing.T) { /* GET USERS */ +func TestGetUsers_SuccessNoFilter(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + + mock.ExpectQuery(`SELECT \* FROM "users" LIMIT \$1(?: OFFSET \$2)?`). + WithArgs(10). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password", "login_retries"}). + AddRow(uuid.New(), "User1", "test@email.com", "password", 0). + AddRow(uuid.New(), "User2", "test2@email.com", "password2", 0)) + + users, err := repo.GetUsers("", 1) + assert.NoError(t, err) + assert.Len(t, users, 2) + assert.NotEmpty(t, users[0].ID) + assert.NotEmpty(t, users[1].ID) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetUsers_SuccessWithFilter(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + + mock.ExpectQuery(`SELECT \* FROM "users" WHERE name ILIKE \$1 LIMIT \$2(?: OFFSET \$3)?`). + WithArgs("%Test%", 10). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password", "login_retries"}). + AddRow(uuid.New(), "Test User", "test@email.com", "password", 0). + AddRow(uuid.New(), "Another Test User", "test2@email.com", "password2", 0)) + + users, err := repo.GetUsers("Test", 1) + assert.NoError(t, err) + assert.Len(t, users, 2) + assert.NotEmpty(t, users[0].ID) + assert.NotEmpty(t, users[1].ID) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + func TestGetUserByID_UserNotFound(t *testing.T) { db, mock, cleanup := setupMockDB(t) db = db.Debug() @@ -385,6 +472,52 @@ func TestGetGoogleSubByUserID_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } +func TestGetBlockStatus_UserNotFound(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectQuery(`SELECT \* FROM "blocks" WHERE user_id = \$1 ORDER BY "blocks"\."user_id" LIMIT \$\d+`). + WithArgs(userID, 1). + WillReturnError(gorm.ErrRecordNotFound) + + block, err := repo.GetBlockStatus(userID) + assert.Nil(t, block) + assert.Error(t, err) + + notFoundErr, ok := err.(*errors.NotFoundError) + assert.True(t, ok) + if ok { + assert.Contains(t, notFoundErr.Message, "block") + } + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetBlockStatus_Success(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + blockedUntil := time.Now().Add(time.Hour) + + mock.ExpectQuery(`SELECT \* FROM "blocks" WHERE user_id = \$1 ORDER BY "blocks"\."user_id" LIMIT \$\d+`). + WithArgs(userID, 1). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "blocked_until"}). + AddRow(userID, blockedUntil)) + + block, err := repo.GetBlockStatus(userID) + assert.NoError(t, err) + assert.NotNil(t, block) + assert.Equal(t, userID, block.UserID) + assert.Equal(t, blockedUntil, block.BlockedUntil) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + /* ACCOUNT ACTIVATION */ func TestRefreshToken_UserNotFound(t *testing.T) { diff --git a/internal/repository/users/repository.go b/internal/repository/users/repository.go index 17a4559..c7ccfbc 100644 --- a/internal/repository/users/repository.go +++ b/internal/repository/users/repository.go @@ -13,6 +13,9 @@ type UsersRepository interface { // and initializes its profile. CreateUser(user *models.User) error + // DeleteUser deletes a user from the database by their ID. + DeleteUser(id uuid.UUID) error + // CreateGoogleUser creates a new user in the database and a GoogleSub table entry // and initializes its profile with its picture. CreateGoogleUser(user *models.User, picture, sub string) error @@ -20,6 +23,9 @@ type UsersRepository interface { // MergeGoogleUser merges a Google user with an existing user account. MergeGoogleUser(userID uuid.UUID, name, picture, sub string) (*models.User, error) + // GetUsers retrieves a list of users from the database. + GetUsers(nameContains string, page int) ([]models.User, error) + // GetUserByID retrieves a user by their ID. GetUserByID(id uuid.UUID) (*models.User, error) @@ -29,6 +35,9 @@ type UsersRepository interface { // GetGoogleSubByUserID retrieves the Google Sub ID for a user by their user ID. GetGoogleSubByUserID(userID uuid.UUID) (string, error) + // GetBlockStatus retrieves the block status of a user by their ID. + GetBlockStatus(id uuid.UUID) (*models.Block, error) + // ActivateAccount sets a user as active and removes the token from db. ActivateAccount(id uuid.UUID, token string) error diff --git a/internal/services/users/auth/login.go b/internal/services/users/auth/login.go index 767d424..ebc5354 100644 --- a/internal/services/users/auth/login.go +++ b/internal/services/users/auth/login.go @@ -162,13 +162,6 @@ func (s *authService) ChangePassword(email string, newPassword, token string) er return nil } -func (s *authService) UpdateUserBlockStatus(id uuid.UUID, blockedUntil time.Time, block bool) error { - if block { - return s.repo.BlockUser(id, blockedUntil) - } - return s.repo.UnblockUser(id) -} - /* HELPER FUNCTIONS */ // googleSignup creates a user using the parsed token payload data. diff --git a/internal/services/users/auth/service.go b/internal/services/users/auth/service.go index 5cb3086..12fcaa7 100644 --- a/internal/services/users/auth/service.go +++ b/internal/services/users/auth/service.go @@ -1,7 +1,6 @@ package auth import ( - "time" "users-microservice/internal/models" "github.com/google/uuid" @@ -30,7 +29,4 @@ type AuthService interface { // ChangePassword changes the user's password using the provided email, new password, and token. ChangePassword(email string, newPassword, token string) error - - // UpdateUserBlockStatus updates the user's block status based on the provided ID and block status. - UpdateUserBlockStatus(id uuid.UUID, blockUntil time.Time, block bool) error } diff --git a/internal/services/users/management/management.go b/internal/services/users/management/management.go new file mode 100644 index 0000000..522d356 --- /dev/null +++ b/internal/services/users/management/management.go @@ -0,0 +1,48 @@ +package management + +import ( + "time" + "users-microservice/config" + "users-microservice/internal/models" + "users-microservice/internal/repository/users" + + "github.com/google/uuid" +) + +type managementService struct { + repo users.UsersRepository + conf *config.Config +} + +func NewManagementService(repo users.UsersRepository, conf *config.Config) ManagementService { + return &managementService{ + repo: repo, + conf: conf, + } +} + +func (s *managementService) UpdateUserBlockStatus(id uuid.UUID, blockedUntil *time.Time, block bool) error { + if block { + return s.repo.BlockUser(id, *blockedUntil) + } + return s.repo.UnblockUser(id) +} + +func (s *managementService) DeleteUser(id uuid.UUID) error { + if err := s.repo.DeleteUser(id); err != nil { + return err + } + return nil +} + +func (s *managementService) GetPlatformUsers(nameContains string, page int) ([]models.User, error) { + return s.repo.GetUsers(nameContains, page) +} + +func (s *managementService) GetBlockedUntil(id uuid.UUID) *time.Time { + block, err := s.repo.GetBlockStatus(id) + if err != nil { + return nil + } + return &block.BlockedUntil +} diff --git a/internal/services/users/management/management_test.go b/internal/services/users/management/management_test.go new file mode 100644 index 0000000..ac49ad6 --- /dev/null +++ b/internal/services/users/management/management_test.go @@ -0,0 +1,91 @@ +package management_test + +import ( + "testing" + "time" + "users-microservice/config" + "users-microservice/internal/errors" + "users-microservice/internal/models" + "users-microservice/internal/repository/memory" + "users-microservice/internal/repository/users" + "users-microservice/internal/services/users/management" + + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/bcrypt" +) + +var managementService management.ManagementService +var repository users.UsersRepository + +func TestMain(m *testing.M) { + repository = memory.NewInMemoryUsersRepository() + + // Create user + hashedPassword, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.DefaultCost) + if err != nil { + panic(err) + } + user := &models.User{ + Name: "Test User", + Email: "test@email.com", + Password: string(hashedPassword), + } + _ = repository.CreateUser(user) + user = &models.User{ + Name: "Test User 2", + Email: "test2@email.com", + Password: string(hashedPassword), + Active: true, + } + _ = repository.CreateUser(user) + + conf := &config.Config{} + + managementService = management.NewManagementService(repository, conf) + + m.Run() +} + +func TestUpdateUserBlockStatus(t *testing.T) { + blockedUntil := time.Now().Add(24 * time.Hour) + user, _ := repository.GetUserByEmail("test@email.com") + + // Test blocking a user + err := managementService.UpdateUserBlockStatus(user.ID, &blockedUntil, true) + assert.NoError(t, err) + + // Verify the user is blocked + getBlockedUntil := managementService.GetBlockedUntil(user.ID) + assert.NotNil(t, getBlockedUntil) + assert.Equal(t, blockedUntil, *getBlockedUntil) + + // Test unblocking the user + err = managementService.UpdateUserBlockStatus(user.ID, nil, false) + assert.NoError(t, err) + + // Verify the user is unblocked + getBlockedUntil = managementService.GetBlockedUntil(user.ID) + assert.Nil(t, getBlockedUntil) +} + +func TestDeleteUser(t *testing.T) { + user, _ := repository.GetUserByEmail("test2@email.com") + err := managementService.DeleteUser(user.ID) + assert.NoError(t, err) + + // Verify the user is deleted + _, err = repository.GetUserByID(user.ID) + assert.Error(t, err) + assert.IsType(t, &errors.NotFoundError{}, err) + + // Test deleting a non-existing user + err = managementService.DeleteUser(user.ID) + assert.Error(t, err) + assert.IsType(t, &errors.NotFoundError{}, err) +} + +func TestGetPlatformUsers(t *testing.T) { + users, err := managementService.GetPlatformUsers("", 1) + assert.NoError(t, err) + assert.Greater(t, len(users), 0) +} diff --git a/internal/services/users/management/service.go b/internal/services/users/management/service.go new file mode 100644 index 0000000..9178c84 --- /dev/null +++ b/internal/services/users/management/service.go @@ -0,0 +1,22 @@ +package management + +import ( + "time" + "users-microservice/internal/models" + + "github.com/google/uuid" +) + +type ManagementService interface { + // UpdateUserBlockStatus updates the user's block status based on the provided ID and block status. + UpdateUserBlockStatus(id uuid.UUID, blockUntil *time.Time, block bool) error + + // DeleteUser deletes a user based on the provided ID. + DeleteUser(id uuid.UUID) error + + // GetPlatformUsers fetches a list of users on the platform (and optionally filter them by name). + GetPlatformUsers(nameContains string, page int) ([]models.User, error) + + // GetBlockedUntil returns the time until which the user is blocked. + GetBlockedUntil(id uuid.UUID) *time.Time +} From 4e02d796df606f2312a1decf7ae2da7424f4ec04 Mon Sep 17 00:00:00 2001 From: maxogod Date: Wed, 11 Jun 2025 02:30:38 -0300 Subject: [PATCH 2/4] feat: Add new management to router and add new middleware for protection --- config/config.go | 10 ++-- internal/middleware/jwt_admin.go | 42 +++++++++++++ internal/middleware/jwt_admin_test.go | 85 +++++++++++++++++++++++++++ internal/router/management_router.go | 25 ++++++++ internal/router/router.go | 1 + internal/router/router_test.go | 7 +++ 6 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 internal/middleware/jwt_admin.go create mode 100644 internal/middleware/jwt_admin_test.go create mode 100644 internal/router/management_router.go diff --git a/config/config.go b/config/config.go index 7a90a66..7eae4d2 100644 --- a/config/config.go +++ b/config/config.go @@ -23,8 +23,9 @@ type Config struct { PAGE_SIZE int // Auth - JWT_SECRET []byte - GOOGLE_CLIENT_ID string + JWT_SECRET []byte + USERS_SERVICE_SECRET []byte + GOOGLE_CLIENT_ID string // Database configuration DB_URL string @@ -52,8 +53,9 @@ func LoadConfig() *Config { BLOCK_DURATION: time.Second * time.Duration(getEnvAsInt("BLOCK_DURATION", 15)), PAGE_SIZE: getEnvAsInt("PAGE_SIZE", 10), - JWT_SECRET: []byte(getEnvOrDefault("JWT_SECRET", "default_secret_key")), - GOOGLE_CLIENT_ID: getEnvOrDefault("GOOGLE_CLIENT_ID", ""), + JWT_SECRET: []byte(getEnvOrDefault("JWT_SECRET", "default_secret_key")), + USERS_SERVICE_SECRET: []byte(getEnvOrDefault("USERS_SERVICE_SECRET", "default_users_service_secret")), + GOOGLE_CLIENT_ID: getEnvOrDefault("GOOGLE_CLIENT_ID", ""), DB_URL: getEnvOrDefault("DB_URL", ""), STORAGE_URL: getEnvOrDefault("STORAGE_URL", ""), diff --git a/internal/middleware/jwt_admin.go b/internal/middleware/jwt_admin.go new file mode 100644 index 0000000..f9c71b9 --- /dev/null +++ b/internal/middleware/jwt_admin.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "fmt" + "strings" + "users-microservice/config" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// JWTAdminMiddleware parses the JWT token included in the request header, +// and checks if the user is an admin. +func JWTAdminMiddleware(conf *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.Next() + return + } + + tokenStrings := strings.TrimPrefix(authHeader, "Bearer ") + token, err := jwt.Parse(tokenStrings, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(conf.USERS_SERVICE_SECRET), nil + }) + if err != nil || !token.Valid { + c.Next() + return + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok { + if isAdmin, ok := claims["isAdmin"].(bool); ok { + c.Set("isAdmin", isAdmin) + } + } + + c.Next() + } +} diff --git a/internal/middleware/jwt_admin_test.go b/internal/middleware/jwt_admin_test.go new file mode 100644 index 0000000..7039dd8 --- /dev/null +++ b/internal/middleware/jwt_admin_test.go @@ -0,0 +1,85 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "users-microservice/config" + "users-microservice/internal/middleware" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" +) + +func generateJWT(secret string, claims jwt.MapClaims) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, _ := token.SignedString([]byte(secret)) + return tokenString +} + +func TestJWTAdminMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + secret := "test-secret" + conf := &config.Config{USERS_SERVICE_SECRET: []byte("test-secret")} + + tests := []struct { + name string + token string + expectedIsAdmin any + }{ + { + name: "No Authorization Header", + token: "", + expectedIsAdmin: nil, + }, + { + name: "Invalid Token", + token: "Bearer invalid.token.here", + expectedIsAdmin: nil, + }, + { + name: "Valid Token Without isAdmin", + token: "Bearer " + generateJWT(secret, jwt.MapClaims{}), + expectedIsAdmin: nil, + }, + { + name: "Valid Token With isAdmin", + token: "Bearer " + generateJWT(secret, jwt.MapClaims{ + "isAdmin": true, + }), + expectedIsAdmin: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := gin.New() + router.Use(middleware.JWTAdminMiddleware(conf)) + router.GET("/test", func(c *gin.Context) { + val, exists := c.Get("isAdmin") + if exists { + c.JSON(200, gin.H{"isAdmin": val}) + } else { + c.JSON(200, gin.H{"isAdmin": nil}) + } + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + if tt.token != "" { + req.Header.Set("Authorization", tt.token) + } + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, 200, resp.Code) + body := resp.Body.String() + if tt.expectedIsAdmin == nil { + assert.Contains(t, body, `"isAdmin":null`) + } else { + assert.Contains(t, body, `"isAdmin":true`) + } + }) + } +} diff --git a/internal/router/management_router.go b/internal/router/management_router.go new file mode 100644 index 0000000..9baf949 --- /dev/null +++ b/internal/router/management_router.go @@ -0,0 +1,25 @@ +package router + +import ( + "users-microservice/config" + managementHandler "users-microservice/internal/handlers/users/management" + "users-microservice/internal/repository/users" + "users-microservice/internal/services/users/management" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// InitManagementRoutes initializes the route Group /management and +// the corresponding handlers. +func InitManagementRoutes(r *gin.Engine, db *gorm.DB, conf *config.Config) { + usersRepository := users.NewUsersRepository(db, conf) + managementService := management.NewManagementService(usersRepository, conf) + handler := managementHandler.NewManagementHandler(managementService, conf) + + group := r.Group("/management") + + group.GET("", handler.GetPlatformUsers) + group.PUT("block_status/:id", handler.UpdateUserBlockStatus) + group.DELETE(":id", handler.DeleteUser) +} diff --git a/internal/router/router.go b/internal/router/router.go index 045b1b0..eda856d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -22,6 +22,7 @@ func SetupRouter(db *gorm.DB, conf *config.Config) *gin.Engine { InitRegisterRoutes(r, db, conf) InitAuthRoutes(r, db, conf) + InitManagementRoutes(r, db, conf) InitProfileRoutes(r, db, conf) InitSwaggerRouter(r) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index f689ba8..a4de68a 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -19,6 +19,13 @@ func TestSetupRouter_RegistersExpectedRoutes(t *testing.T) { "POST /auth/login", "POST /auth/google", "GET /auth", + "GET /auth/activation/:id", + "POST /auth/recovery", + + // Management + "GET /management", + "PUT /management/block_status/:id", + "DELETE /management/:id", // Register "POST /signup", From b90cf549ceb815c400d1875a6ff6d429e96eb20f Mon Sep 17 00:00:00 2001 From: maxogod Date: Wed, 11 Jun 2025 02:45:26 -0300 Subject: [PATCH 3/4] fix: dont return model, return dto --- docs/docs.go | 160 ++++++++++++++++++ docs/swagger.json | 160 ++++++++++++++++++ docs/swagger.yaml | 107 ++++++++++++ .../handlers/users/management/management.go | 4 +- 4 files changed, 429 insertions(+), 2 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 29a85e6..f542c62 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -268,6 +268,149 @@ const docTemplate = `{ } } }, + "/management": { + "get": { + "description": "Fetch a list of users on the platform (and optionally filter them by name).", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "GetPlatformUsers", + "parameters": [ + { + "type": "string", + "description": "Filter users by name contains", + "name": "name_contains", + "in": "query" + }, + { + "type": "integer", + "description": "Page number for pagination (default is 1)", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Successful retrieval of users (Wrapped in data envelope)", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.UserResponseDTO" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, + "/management/block_status/{id}": { + "put": { + "description": "Update the user's block status based on the provided ID and block status.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "UpdateUserBlockStatus", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update user block status data", + "name": "blockStatus", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.UpdateUserBlockStatusDTO" + } + } + ], + "responses": { + "204": { + "description": "Successful update of user block status", + "schema": { + "$ref": "#/definitions/dto.UserResponseDTO" + } + }, + "400": { + "description": "Bad request body", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "403": { + "description": "Unauthorized access", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, + "/management/{id}": { + "delete": { + "description": "Delete a user based on the provided ID.", + "tags": [ + "users" + ], + "summary": "DeleteUser", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "User successfully deleted" + }, + "400": { + "description": "Bad request body", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "403": { + "description": "Unauthorized access", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/profile": { "get": { "description": "Get profile of the current session", @@ -689,6 +832,20 @@ const docTemplate = `{ } } }, + "dto.UpdateUserBlockStatusDTO": { + "type": "object", + "required": [ + "block" + ], + "properties": { + "block": { + "type": "boolean" + }, + "block_until": { + "type": "string" + } + } + }, "dto.UserResponseDTO": { "type": "object", "required": [ @@ -701,6 +858,9 @@ const docTemplate = `{ "active": { "type": "boolean" }, + "blocked_until": { + "type": "string" + }, "email": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 251e6d1..ef757e5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -261,6 +261,149 @@ } } }, + "/management": { + "get": { + "description": "Fetch a list of users on the platform (and optionally filter them by name).", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "GetPlatformUsers", + "parameters": [ + { + "type": "string", + "description": "Filter users by name contains", + "name": "name_contains", + "in": "query" + }, + { + "type": "integer", + "description": "Page number for pagination (default is 1)", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Successful retrieval of users (Wrapped in data envelope)", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.UserResponseDTO" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, + "/management/block_status/{id}": { + "put": { + "description": "Update the user's block status based on the provided ID and block status.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "UpdateUserBlockStatus", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update user block status data", + "name": "blockStatus", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.UpdateUserBlockStatusDTO" + } + } + ], + "responses": { + "204": { + "description": "Successful update of user block status", + "schema": { + "$ref": "#/definitions/dto.UserResponseDTO" + } + }, + "400": { + "description": "Bad request body", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "403": { + "description": "Unauthorized access", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, + "/management/{id}": { + "delete": { + "description": "Delete a user based on the provided ID.", + "tags": [ + "users" + ], + "summary": "DeleteUser", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "User successfully deleted" + }, + "400": { + "description": "Bad request body", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "403": { + "description": "Unauthorized access", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/profile": { "get": { "description": "Get profile of the current session", @@ -682,6 +825,20 @@ } } }, + "dto.UpdateUserBlockStatusDTO": { + "type": "object", + "required": [ + "block" + ], + "properties": { + "block": { + "type": "boolean" + }, + "block_until": { + "type": "string" + } + } + }, "dto.UserResponseDTO": { "type": "object", "required": [ @@ -694,6 +851,9 @@ "active": { "type": "boolean" }, + "blocked_until": { + "type": "string" + }, "email": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6715694..f1e28ae 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -93,10 +93,21 @@ definitions: - name - password type: object + dto.UpdateUserBlockStatusDTO: + properties: + block: + type: boolean + block_until: + type: string + required: + - block + type: object dto.UserResponseDTO: properties: active: type: boolean + blocked_until: + type: string email: type: string id: @@ -289,6 +300,102 @@ paths: summary: Password Recovery tags: - users + /management: + get: + description: Fetch a list of users on the platform (and optionally filter them + by name). + parameters: + - description: Filter users by name contains + in: query + name: name_contains + type: string + - description: Page number for pagination (default is 1) + in: query + name: page + type: integer + produces: + - application/json + responses: + "200": + description: Successful retrieval of users (Wrapped in data envelope) + schema: + items: + $ref: '#/definitions/dto.UserResponseDTO' + type: array + "500": + description: Internal server error + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: GetPlatformUsers + tags: + - users + /management/{id}: + delete: + description: Delete a user based on the provided ID. + parameters: + - description: User ID + in: path + name: id + required: true + type: string + responses: + "204": + description: User successfully deleted + "400": + description: Bad request body + schema: + $ref: '#/definitions/dto.ErrorResponse' + "403": + description: Unauthorized access + schema: + $ref: '#/definitions/dto.ErrorResponse' + "404": + description: User not found + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: DeleteUser + tags: + - users + /management/block_status/{id}: + put: + consumes: + - application/json + description: Update the user's block status based on the provided ID and block + status. + parameters: + - description: User ID + in: path + name: id + required: true + type: string + - description: Update user block status data + in: body + name: blockStatus + required: true + schema: + $ref: '#/definitions/dto.UpdateUserBlockStatusDTO' + produces: + - application/json + responses: + "204": + description: Successful update of user block status + schema: + $ref: '#/definitions/dto.UserResponseDTO' + "400": + description: Bad request body + schema: + $ref: '#/definitions/dto.ErrorResponse' + "403": + description: Unauthorized access + schema: + $ref: '#/definitions/dto.ErrorResponse' + "404": + description: User not found + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: UpdateUserBlockStatus + tags: + - users /profile: get: description: Get profile of the current session diff --git a/internal/handlers/users/management/management.go b/internal/handlers/users/management/management.go index d0c2f2e..216df4b 100644 --- a/internal/handlers/users/management/management.go +++ b/internal/handlers/users/management/management.go @@ -63,7 +63,7 @@ func (h *managementHandler) GetPlatformUsers(c *gin.Context) { } } - handlers.HandleSuccessResponse(c, http.StatusOK, users) + handlers.HandleSuccessResponse(c, http.StatusOK, usersDTO) } // @Summary UpdateUserBlockStatus @@ -72,7 +72,7 @@ func (h *managementHandler) GetPlatformUsers(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "User ID" -// @Param body dto.UpdateUserBlockStatusDTO true "Update user block status data" +// @Param blockStatus body dto.UpdateUserBlockStatusDTO true "Update user block status data" // @Success 204 {object} dto.UserResponseDTO "Successful update of user block status" // @Failure 400 {object} dto.ErrorResponse "Bad request body" // @Failure 403 {object} dto.ErrorResponse "Unauthorized access" From d391f8bd74d126a679b433ba35938604a4f327a6 Mon Sep 17 00:00:00 2001 From: maxogod Date: Wed, 11 Jun 2025 12:16:48 -0300 Subject: [PATCH 4/4] fix: Use middleware in router & DTO non required for bool which defaults to false --- internal/dto/management_dto.go | 4 ++-- internal/handlers/users/management/management.go | 2 +- internal/router/router.go | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/dto/management_dto.go b/internal/dto/management_dto.go index eaf764c..91bee78 100644 --- a/internal/dto/management_dto.go +++ b/internal/dto/management_dto.go @@ -3,6 +3,6 @@ package dto import "time" type UpdateUserBlockStatusDTO struct { - Block bool `json:"block" binding:"required"` - BlockUntil *time.Time `json:"block_until"` + Block bool `json:"block"` + BlockUntil *time.Time `json:"block_until,omitempty"` } diff --git a/internal/handlers/users/management/management.go b/internal/handlers/users/management/management.go index 216df4b..488e7af 100644 --- a/internal/handlers/users/management/management.go +++ b/internal/handlers/users/management/management.go @@ -94,7 +94,7 @@ func (h *managementHandler) UpdateUserBlockStatus(c *gin.Context) { var blockStatus dto.UpdateUserBlockStatusDTO if err := c.ShouldBindJSON(&blockStatus); err != nil { - _ = c.Error(errors.NewBadRequestError("invalid request body")) + _ = c.Error(errors.NewBadRequestError("invalid request body: " + err.Error())) return } else if blockStatus.Block && blockStatus.BlockUntil == nil { _ = c.Error(errors.NewBadRequestError("blocked_until is required when block is true")) diff --git a/internal/router/router.go b/internal/router/router.go index eda856d..dfa4ec9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -19,6 +19,7 @@ func SetupRouter(db *gorm.DB, conf *config.Config) *gin.Engine { } r.Use(middleware.ErrorHandlerMiddleware(conf)) r.Use(middleware.JWTAuthMiddleware(conf)) + r.Use(middleware.JWTAdminMiddleware(conf)) InitRegisterRoutes(r, db, conf) InitAuthRoutes(r, db, conf)