diff --git a/backend/.gitignore b/backend/.gitignore index 315b7cd..bb2cd17 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -9,4 +9,6 @@ .vscode .DS_Store .idea -.env \ No newline at end of file +.env +/uploads/ +/test/ \ No newline at end of file diff --git a/backend/api/cors.go b/backend/api/cors.go index e837cd5..301eea1 100644 --- a/backend/api/cors.go +++ b/backend/api/cors.go @@ -10,7 +10,7 @@ import ( func Config() gin.HandlerFunc { return cors.New(cors.Config{ AllowOrigins: []string{"*"}, - AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 977b5ea..bac267b 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -6,6 +6,7 @@ import ( "takumi/internal/config" "takumi/internal/database" "takumi/internal/modules/authorization" + "takumi/internal/modules/user" "takumi/internal/routes" "github.com/gin-gonic/gin" @@ -19,15 +20,11 @@ func main() { dbHandler := database.InitDB(cfg.DBSource) app := setupGin(cfg) + app.Static("/uploads", "./uploads") router := routes.NewTakumiRouter(app, "/api", "/v1") - authService, err := authorization.InitAuthService(dbHandler) - if err != nil { - log.Fatal("error initializing authorization service: ", err) - return - } - authHandlers := authorization.NewHandler(authService) - router.RegisterAuthRoutes(authHandlers) + InitializeModule(dbHandler, authorization.InitAuthService, authorization.NewHandler, router.RegisterAuthRoutes) + InitializeModule(dbHandler, user.InitUserService, user.NewHandler, router.RegisterUserRoutes) err = app.Run(":" + cfg.Port) if err != nil { @@ -53,3 +50,22 @@ func setupGin(cfg *config.Config) *gin.Engine { return app } + +type Service interface{} +type Handler interface{} + +func InitializeModule[T Service, H Handler]( + dbHandler database.DBHandler, + initService func(dbHandler database.DBHandler) (T, error), + createHandler func(T) H, + registerRoutes func(H)) { + service, err := initService(dbHandler) + if err != nil { + log.Fatalf("error initializing service: %v", err) + return + } + + handler := createHandler(service) + + registerRoutes(handler) +} diff --git a/backend/internal/database/postgres.go b/backend/internal/database/postgres.go index 3c362ac..b659293 100644 --- a/backend/internal/database/postgres.go +++ b/backend/internal/database/postgres.go @@ -2,7 +2,7 @@ package database import ( "log" - "takumi/internal/modules/authorization/types" + "takumi/internal/modules/user/types" "gorm.io/driver/postgres" "gorm.io/gorm" diff --git a/backend/internal/modules/authorization/repo.go b/backend/internal/modules/authorization/repo.go index 1ee98dc..09b475e 100644 --- a/backend/internal/modules/authorization/repo.go +++ b/backend/internal/modules/authorization/repo.go @@ -51,9 +51,7 @@ func CreateUser(registration *types.User, handler *database.DBHandler) (*types.U Password: password, Gender: registration.Gender, BirthDate: registration.BirthDate, - Role: "USER", CreatedAt: time.Now(), - Coins: 0, } err = handler.DB.Create(user).Error diff --git a/backend/internal/modules/authorization/types/user-model.go b/backend/internal/modules/authorization/types/user-model.go index e3d3ecb..65766fb 100644 --- a/backend/internal/modules/authorization/types/user-model.go +++ b/backend/internal/modules/authorization/types/user-model.go @@ -3,15 +3,16 @@ package types import "time" type User struct { - ID int `gorm:"primaryKey;autoIncrement" json:"id"` - Username string `gorm:"unique;not null" json:"username"` - FirstName string `gorm:"not null" json:"firstName"` - LastName string `gorm:"not null" json:"lastName"` - Email string `gorm:"unique;not null" json:"email"` - Gender string `json:"gender"` - BirthDate time.Time `json:"birthDate"` - Password string `gorm:"not null" json:"password"` - Role string `gorm:"default:USER" json:"role"` - CreatedAt time.Time `json:"createdAt"` - Coins int `gorm:"default:0" json:"coins"` + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Username string `gorm:"unique;not null" json:"username"` + FirstName string `gorm:"not null" json:"firstName"` + LastName string `gorm:"not null" json:"lastName"` + Email string `gorm:"unique;not null" json:"email"` + Gender string `json:"gender"` + BirthDate time.Time `json:"birthDate"` + Password string `gorm:"not null" json:"password"` + Role string `gorm:"default:USER" json:"role"` + CreatedAt time.Time `json:"createdAt"` + Coins int `gorm:"default:0" json:"coins"` + ProfilePicture string `gorm:"default:''" json:"profilePicture"` } diff --git a/backend/internal/modules/user/handler.go b/backend/internal/modules/user/handler.go new file mode 100644 index 0000000..ab77531 --- /dev/null +++ b/backend/internal/modules/user/handler.go @@ -0,0 +1,167 @@ +package user + +import ( + "fmt" + "log" + "net/http" + "path/filepath" + "strconv" + "takumi/internal/modules/user/types" + "takumi/pkg/utils" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + Service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{ + Service: service, + } +} + +func (h *Handler) GetUserByIDHandler(c *gin.Context) { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: Invalid user ID", http.StatusBadRequest) + return + } + + user, err := h.Service.GetUserByID(c, userID) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: "+err.Error(), http.StatusNotFound) + return + } + + utils.SendSuccessJSON(c, user) +} + +func (h *Handler) DeleteUserByIDHandler(c *gin.Context) { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: Invalid user ID", http.StatusBadRequest) + return + } + + err = h.Service.DeleteUserByID(c, userID) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: "+err.Error(), http.StatusNotFound) + return + } + + utils.SendMessageWithStatus(c, "User deleted successfully", http.StatusOK) +} + +func (h *Handler) UpdateUserParamsHandler(c *gin.Context) { + update := &types.User{} + if err := c.ShouldBindJSON(update); err != nil { + utils.SendMessageWithStatus(c, "ERROR: Invalid request body", http.StatusBadRequest) + return + } + + updatedUser, err := h.Service.UpdateUserParams(c, *update) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: "+err.Error(), http.StatusInternalServerError) + return + } + + utils.SendSuccessJSON(c, updatedUser) +} + +func (h *Handler) PatchUserParamsHandler(c *gin.Context) { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: Invalid user ID", http.StatusBadRequest) + return + } + + updateData := map[string]interface{}{} + if err := c.ShouldBindJSON(&updateData); err != nil { + utils.SendMessageWithStatus(c, "ERROR: Invalid request body", http.StatusBadRequest) + return + } + + updatedUser, err := h.Service.PatchUserParams(c, userID, updateData) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: "+err.Error(), http.StatusInternalServerError) + return + } + + utils.SendSuccessJSON(c, updatedUser) +} + +func (h *Handler) UpdateProfilePictureHandler(c *gin.Context) { + userIDParam := c.Param("id") + userID, err := strconv.Atoi(userIDParam) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: Invalid user ID", http.StatusBadRequest) + return + } + + file, _, err := c.Request.FormFile("profilePicture") + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: File upload failed", http.StatusBadRequest) + return + } + defer file.Close() + + fileName := fmt.Sprintf("user_%d_profile_pic.jpg", userID) + if err := utils.SaveFile(file, "profile-pictures", fileName); err != nil { + log.Printf("Failed to save profile picture for user %d: %v", userID, err) + utils.SendMessageWithStatus(c, "ERROR: Could not save profile picture", http.StatusInternalServerError) + return + } + + profilePictureURL := filepath.Join("profile-pictures", fileName) + updatedUser, err := h.Service.UpdateProfilePicture(c, userID, profilePictureURL) + if err != nil { + log.Printf("Error updating profile picture for user %d: %v", userID, err) + utils.SendMessageWithStatus(c, err.Error(), http.StatusInternalServerError) + return + } + + utils.SendSuccessJSON(c, updatedUser) +} + +func (h *Handler) GetProfilePictureByUserID(c *gin.Context) { + userIDParam := c.Param("id") + userID, err := strconv.Atoi(userIDParam) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: Invalid user ID", http.StatusBadRequest) + return + } + + profilePictureURL, err := h.Service.GetProfilePictureByUserID(userID) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: "+err.Error(), http.StatusInternalServerError) + return + } + + utils.SendSuccessJSON(c, gin.H{"profilePictureURL": profilePictureURL}) +} + +func (h *Handler) DeleteProfilePictureHandler(c *gin.Context) { + userIDParam := c.Param("id") + userID, err := strconv.Atoi(userIDParam) + if err != nil { + utils.SendMessageWithStatus(c, "ERROR: Invalid user ID", http.StatusBadRequest) + return + } + + deletedUser, err := h.Service.DeleteProfilePicture(c, userID) + + fileName := fmt.Sprintf("user_%d_profile_pic.jpg", userID) + if err := utils.DeleteFile("profile-pictures", fileName); err != nil { + log.Printf("Failed to delete: %s, error: %v", fileName, err) + return + } + + if err != nil { + utils.SendMessageWithStatus(c, err.Error(), http.StatusInternalServerError) + return + } + + utils.SendSuccessJSON(c, deletedUser) +} diff --git a/backend/internal/modules/user/repo.go b/backend/internal/modules/user/repo.go new file mode 100644 index 0000000..f7a96a2 --- /dev/null +++ b/backend/internal/modules/user/repo.go @@ -0,0 +1,116 @@ +package user + +import ( + "errors" + "log" + "takumi/internal/database" + "takumi/internal/modules/user/types" + "takumi/pkg/utils" + + "gorm.io/gorm" +) + +func getUserByID(id int, handler database.DBHandler) (*types.User, error) { + user := types.User{} + query := types.User{ID: id} + + if err := handler.DB.First(&user, &query).Error; err == gorm.ErrRecordNotFound { + return nil, errors.New("user is not found") + } + + return &user, nil +} + +func DeleteUserByID(id int, handler database.DBHandler) error { + user := types.User{} + query := types.User{ID: id} + + if err := handler.DB.First(&user, &query).Error; err != nil { + return err + } + + return handler.DB.Delete(&user).Error +} + +func UpdateUserParams(update types.User, handler database.DBHandler) (*types.User, error) { + user := types.User{} + + if err := handler.DB.First(&user, update.ID).Error; err != nil { + return nil, err + } + + if err := handler.DB.Model(&user).Updates(update).Error; err != nil { + return nil, err + } + + return &user, nil +} + +func PatchUserParams(userID int, updateData map[string]interface{}, handler database.DBHandler) (*types.User, error) { + user := types.User{} + + if err := handler.DB.First(&user, userID).Error; err != nil { + return nil, err + } + + if err := handler.DB.Model(&user).Updates(updateData).Error; err != nil { + return nil, err + } + + return &user, nil +} + +func UpdateUserProfilePicture(userID int, profilePictureURL string, handler database.DBHandler) (*types.User, error) { + user := types.User{} + tx := handler.DB.Begin() + + if err := tx.First(&user, userID).Error; err != nil { + tx.Rollback() + return nil, err + } + + if user.ProfilePicture != "" && user.ProfilePicture != "default.jpg" { + if err := utils.DeleteFile("profile-pictures", user.ProfilePicture); err != nil { + log.Printf("Failed to delete: %s, error: %v", user.ProfilePicture, err) + tx.Rollback() + return nil, err + } + } + + user.ProfilePicture = profilePictureURL + if err := tx.Save(&user).Error; err != nil { + tx.Rollback() + return nil, err + } + + tx.Commit() + return &user, nil +} + +func GetProfilePictureByUserID(userID int, handler database.DBHandler) (string, error) { + user := types.User{} + + if err := handler.DB.Select("profile_picture").First(&user, userID).Error; err != nil { + return "", err + } + + if user.ProfilePicture == "" { + return "", errors.New("no profile picture found for this user") + } + + return user.ProfilePicture, nil +} + +func DeleteUserProfilePicture(userID int, handler database.DBHandler) (*types.User, error) { + user := types.User{} + if err := handler.DB.First(&user, userID).Error; err != nil { + return nil, err + } + + user.ProfilePicture = "" + if err := handler.DB.Save(&user).Error; err != nil { + return nil, err + } + + return &user, nil +} diff --git a/backend/internal/modules/user/service.go b/backend/internal/modules/user/service.go new file mode 100644 index 0000000..c38ea04 --- /dev/null +++ b/backend/internal/modules/user/service.go @@ -0,0 +1,97 @@ +package user + +import ( + "errors" + "net/http" + "takumi/internal/database" + "takumi/internal/modules/user/types" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type Service struct { + Handler database.DBHandler +} + +func InitUserService(handler database.DBHandler) (*Service, error) { + service := &Service{ + Handler: handler, + } + return service, nil +} + +func (s *Service) GetUserByID(c *gin.Context, userID int) (*types.User, error) { + user, err := getUserByID(userID, s.Handler) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.AbortWithError(http.StatusNotFound, errors.New("user not found")) + return nil, errors.New("user not found") + } + c.AbortWithError(http.StatusInternalServerError, err) + return nil, errors.New("failed to retrieve user") + } + return user, nil +} + +func (s *Service) DeleteUserByID(c *gin.Context, userID int) error { + if err := DeleteUserByID(userID, s.Handler); err != nil { + if err == gorm.ErrRecordNotFound { + c.AbortWithError(http.StatusNotFound, errors.New("user not found")) + return errors.New("user not found") + } + c.AbortWithError(http.StatusInternalServerError, err) + return errors.New("failed to delete user") + } + return nil +} + +func (s *Service) UpdateUserParams(c *gin.Context, update types.User) (*types.User, error) { + user, err := UpdateUserParams(update, s.Handler) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.AbortWithError(http.StatusNotFound, errors.New("user not found")) + return nil, errors.New("user not found") + } + c.AbortWithError(http.StatusInternalServerError, err) + return nil, errors.New("failed to update user") + } + return user, nil +} + +func (s *Service) PatchUserParams(c *gin.Context, userID int, updateData map[string]interface{}) (*types.User, error) { + user, err := PatchUserParams(userID, updateData, s.Handler) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.AbortWithError(http.StatusNotFound, errors.New("user not found")) + return nil, errors.New("user not found") + } + c.AbortWithError(http.StatusInternalServerError, err) + return nil, errors.New("failed to update user") + } + return user, nil +} + +func (s *Service) UpdateProfilePicture(c *gin.Context, userID int, profilePictureURL string) (*types.User, error) { + user, err := UpdateUserProfilePicture(userID, profilePictureURL, s.Handler) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return nil, errors.New("could not update profile picture") + } + + return user, nil +} + +func (s *Service) GetProfilePictureByUserID(userID int) (string, error) { + return GetProfilePictureByUserID(userID, s.Handler) +} + +func (s *Service) DeleteProfilePicture(c *gin.Context, userID int) (*types.User, error) { + user, err := DeleteUserProfilePicture(userID, s.Handler) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return nil, errors.New("could not delete profile picture") + } + + return user, nil +} diff --git a/backend/internal/modules/user/types/user-model.go b/backend/internal/modules/user/types/user-model.go new file mode 100644 index 0000000..65766fb --- /dev/null +++ b/backend/internal/modules/user/types/user-model.go @@ -0,0 +1,18 @@ +package types + +import "time" + +type User struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Username string `gorm:"unique;not null" json:"username"` + FirstName string `gorm:"not null" json:"firstName"` + LastName string `gorm:"not null" json:"lastName"` + Email string `gorm:"unique;not null" json:"email"` + Gender string `json:"gender"` + BirthDate time.Time `json:"birthDate"` + Password string `gorm:"not null" json:"password"` + Role string `gorm:"default:USER" json:"role"` + CreatedAt time.Time `json:"createdAt"` + Coins int `gorm:"default:0" json:"coins"` + ProfilePicture string `gorm:"default:''" json:"profilePicture"` +} diff --git a/backend/internal/routes/router.go b/backend/internal/routes/router.go index 78fde8f..fb1d2b0 100644 --- a/backend/internal/routes/router.go +++ b/backend/internal/routes/router.go @@ -2,6 +2,7 @@ package routes import ( "takumi/internal/modules/authorization" + "takumi/internal/modules/user" "github.com/gin-gonic/gin" ) @@ -28,3 +29,15 @@ func (tr *TakumiRouter) RegisterAuthRoutes(handler *authorization.Handler) { router.POST("/signup", handler.SignUpHandler) router.GET("/current-user", handler.GetCurrentUser) } + +func (tr *TakumiRouter) RegisterUserRoutes(handler *user.Handler) { + router := tr.Routes.Group("/user") + + router.GET("/:id", handler.GetUserByIDHandler) + router.DELETE("/:id", handler.DeleteUserByIDHandler) + router.PUT("/update", handler.UpdateUserParamsHandler) + router.PATCH("/update/:id", handler.PatchUserParamsHandler) + router.POST("/:id/profile-picture", handler.UpdateProfilePictureHandler) + router.GET("/:id/profile-picture", handler.GetProfilePictureByUserID) + router.DELETE("/:id/profile-picture", handler.DeleteProfilePictureHandler) +} diff --git a/backend/pkg/utils/file.go b/backend/pkg/utils/file.go new file mode 100644 index 0000000..a0a8f72 --- /dev/null +++ b/backend/pkg/utils/file.go @@ -0,0 +1,46 @@ +package utils + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +const FilesDir = "uploads/" + +func DeleteFile(branch, filePath string) error { + fullPath := filepath.Join(FilesDir, branch, filePath) + + if _, err := os.Stat(fullPath); err == nil { + if err := os.Remove(fullPath); err != nil { + return fmt.Errorf("failed to delete file %s: %w", fullPath, err) + } + } else if os.IsNotExist(err) { + return nil + } else { + return fmt.Errorf("failed to check file %s: %w", fullPath, err) + } + + return nil +} + +func SaveFile(file io.Reader, branch, filePath string) error { + fullPath := filepath.Join(FilesDir, branch) + if err := os.MkdirAll(fullPath, os.ModePerm); err != nil { + return fmt.Errorf("failed to create directory %s: %w", fullPath, err) + } + + fullFilePath := filepath.Join(fullPath, filePath) + outFile, err := os.Create(fullFilePath) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", fullFilePath, err) + } + defer outFile.Close() + + if _, err := io.Copy(outFile, file); err != nil { + return fmt.Errorf("failed to copy file content to %s: %w", fullFilePath, err) + } + + return nil +} diff --git a/backend/pkg/utils/logger.go b/backend/pkg/utils/logger.go index d4b585b..5c5da06 100644 --- a/backend/pkg/utils/logger.go +++ b/backend/pkg/utils/logger.go @@ -1 +1,28 @@ package utils + +import ( + "fmt" + "log" + "os" + "time" +) + +type TakumiLogger struct { + logger *log.Logger +} + +func NewLogger() *TakumiLogger { + return &TakumiLogger{ + logger: log.New(os.Stdout, "", log.LstdFlags), + } +} + +func (l *TakumiLogger) LogError(message string, err error, details map[string]interface{}) { + logMsg := fmt.Sprintf("%s | ERROR: %s | DETAILS: %v | %s", time.Now().Format(time.RFC3339), message, details, err) + l.logger.Println(logMsg) +} + +func (l *TakumiLogger) LogInfo(message string, details map[string]interface{}) { + logMsg := fmt.Sprintf("%s | INFO: %s | DETAILS: %v", time.Now().Format(time.RFC3339), message, details) + l.logger.Println(logMsg) +}