From d6b91acdda2155439826f2c8416ba4be73a6f66d Mon Sep 17 00:00:00 2001 From: Cipher Vance Date: Sat, 22 Nov 2025 19:51:16 -0600 Subject: [PATCH] feat: implement Phase 2 - Equipment Management and Training Zones --- cmd/server/main.go | 14 ++- internal/equipment/handler.go | 171 ++++++++++++++++++++++++++++ internal/equipment/model.go | 43 +++++++ internal/equipment/repoistory.go | 56 +++++++++ internal/equipment/service.go | 190 +++++++++++++++++++++++++++++++ internal/user/handler.go | 28 ++++- internal/user/model.go | 20 ++-- internal/user/repository.go | 33 +++++- internal/user/service.go | 10 ++ 9 files changed, 548 insertions(+), 17 deletions(-) create mode 100644 internal/equipment/handler.go create mode 100644 internal/equipment/model.go create mode 100644 internal/equipment/repoistory.go create mode 100644 internal/equipment/service.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 39dea12..c160287 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,6 +11,7 @@ import ( "rideaware/internal/auth" "rideaware/internal/config" + "rideaware/internal/equipment" "rideaware/internal/middleware" "rideaware/internal/user" "rideaware/pkg/database" @@ -19,7 +20,7 @@ import ( func main() { godotenv.Load() - // Initialize database connection + // Initialize database database.Init() defer database.Close() @@ -29,6 +30,7 @@ func main() { &user.Profile{}, &user.PasswordReset{}, &user.Session{}, + &equipment.Equipment{}, ); err != nil { log.Fatalf("Failed to migrate database: %v", err) } @@ -84,6 +86,16 @@ func setupRoutes(r *chi.Mux) { userHandler := user.NewHandler() r.Get("/profile", userHandler.GetProfile) r.Put("/profile", userHandler.UpdateProfile) + + // Equipment routes + equipmentHandler := equipment.NewHandler() + r.Post("/equipment", equipmentHandler.CreateEquipment) + r.Get("/equipment", equipmentHandler.GetEquipment) + r.Put("/equipment", equipmentHandler.UpdateEquipment) + r.Delete("/equipment", equipmentHandler.DeleteEquipment) + + // Training zones + r.Get("/zones", equipmentHandler.GetTrainingZones) }) } diff --git a/internal/equipment/handler.go b/internal/equipment/handler.go new file mode 100644 index 0000000..5c353f8 --- /dev/null +++ b/internal/equipment/handler.go @@ -0,0 +1,171 @@ +package equipment + +import ( + "encoding/json" + "net/http" + "strconv" + + "rideaware/internal/config" + "rideaware/internal/middleware" + "rideaware/internal/user" +) + +type Handler struct { + service *Service + userService *user.Service +} + +func NewHandler() *Handler { + return &Handler{ + service: NewService(), + userService: user.NewService(), + } +} + +// CreateEquipment POST /api/equipment +func (h *Handler) CreateEquipment(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims) + + var req struct { + Name string `json:"name"` + Type string `json:"type"` + Brand string `json:"brand"` + Model string `json:"model"` + Weight float64 `json:"weight"` + Notes string `json:"notes"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"}) + return + } + + equipment, err := h.service.CreateEquipment( + claims.UserID, + req.Name, + req.Type, + req.Brand, + req.Model, + req.Weight, + req.Notes, + ) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(equipment) +} + +// GetEquipment GET /api/equipment +func (h *Handler) GetEquipment(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims) + + equipment, err := h.service.GetUserEquipment(claims.UserID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "failed to fetch equipment"}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(equipment) +} + +// UpdateEquipment PUT /api/equipment +func (h *Handler) UpdateEquipment(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims) + + idStr := r.URL.Query().Get("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid equipment id"}) + return + } + + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"}) + return + } + + equipment, err := h.service.UpdateEquipment(uint(id), claims.UserID, req) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(equipment) +} + +// DeleteEquipment DELETE /api/equipment +func (h *Handler) DeleteEquipment(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims) + + idStr := r.URL.Query().Get("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid equipment id"}) + return + } + + if err := h.service.DeleteEquipment(uint(id), claims.UserID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNoContent) +} + +// GetTrainingZones GET /api/zones +func (h *Handler) GetTrainingZones(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims) + + profile, err := h.userService.GetUserByID(claims.UserID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "user not found"}) + return + } + + // Check if profile exists + if profile == nil || profile.Profile == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "user profile not found"}) + return + } + + zones := map[string]interface{}{} + + if profile.Profile.MaxHR > 0 { + zones["hr_zones"] = h.service.CalculateHRZones(profile.Profile.MaxHR, profile.Profile.RestingHR) + } + + if profile.Profile.FTP > 0 { + zones["power_zones"] = h.service.CalculatePowerZones(profile.Profile.FTP) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(zones) +} \ No newline at end of file diff --git a/internal/equipment/model.go b/internal/equipment/model.go new file mode 100644 index 0000000..de86569 --- /dev/null +++ b/internal/equipment/model.go @@ -0,0 +1,43 @@ +package equipment + +import "time" + +type Equipment struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Name string `gorm:"not null" json:"name"` + Type string `gorm:"not null" json:"type"` // "bike", "shoes", "helmet", etc. + Brand string `gorm:"default:''" json:"brand"` + Model string `gorm:"default:''" json:"model"` + Weight float64 `gorm:"default:0" json:"weight"` // grams + Notes string `gorm:"default:''" json:"notes"` + Active bool `gorm:"default:true" json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TrainingZone struct { + ID uint `json:"id"` + Name string `json:"name"` + Min int `json:"min"` + Max int `json:"max"` + Color string `json:"color"` +} + +type HRZones struct { + Zone1 TrainingZone `json:"zone_1"` // Recovery + Zone2 TrainingZone `json:"zone_2"` // Endurance + Zone3 TrainingZone `json:"zone_3"` // Tempo + Zone4 TrainingZone `json:"zone_4"` // Threshold + Zone5 TrainingZone `json:"zone_5"` // VO2 Max +} + +type PowerZones struct { + Zone1 TrainingZone `json:"zone_1"` // Active Recovery + Zone2 TrainingZone `json:"zone_2"` // Endurance + Zone3 TrainingZone `json:"zone_3"` // Sweet Spot + Zone4 TrainingZone `json:"zone_4"` // Threshold + Zone5 TrainingZone `json:"zone_5"` // VO2 Max + Zone6 TrainingZone `json:"zone_6"` // Anaerobic + Zone7 TrainingZone `json:"zone_7"` // Neuromuscular +} \ No newline at end of file diff --git a/internal/equipment/repoistory.go b/internal/equipment/repoistory.go new file mode 100644 index 0000000..71cdb9f --- /dev/null +++ b/internal/equipment/repoistory.go @@ -0,0 +1,56 @@ +package equipment + +import ( + "errors" + "rideaware/pkg/database" + "gorm.io/gorm" +) + +type Repository struct{} + +func NewRepository() *Repository { + return &Repository{} +} + +func (r *Repository) CreateEquipment(equipment *Equipment) error { + return database.DB.Create(equipment).Error +} + +func (r *Repository) GetEquipmentByID(id, userID uint) (*Equipment, error) { + var equipment Equipment + if err := database.DB.Where("id = ? AND user_id = ?", id, userID). + First(&equipment).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("equipment not found") + } + return nil, err + } + return &equipment, nil +} + +func (r *Repository) GetUserEquipment(userID uint) ([]Equipment, error) { + var equipment []Equipment + if err := database.DB.Where("user_id = ?", userID). + Find(&equipment).Error; err != nil { + return nil, err + } + return equipment, nil +} + +func (r *Repository) GetActiveEquipment(userID uint) ([]Equipment, error) { + var equipment []Equipment + if err := database.DB.Where("user_id = ? AND active = ?", userID, true). + Find(&equipment).Error; err != nil { + return nil, err + } + return equipment, nil +} + +func (r *Repository) UpdateEquipment(equipment *Equipment) error { + return database.DB.Save(equipment).Error +} + +func (r *Repository) DeleteEquipment(id, userID uint) error { + return database.DB.Where("id = ? AND user_id = ?", id, userID). + Delete(&Equipment{}).Error +} \ No newline at end of file diff --git a/internal/equipment/service.go b/internal/equipment/service.go new file mode 100644 index 0000000..86d6805 --- /dev/null +++ b/internal/equipment/service.go @@ -0,0 +1,190 @@ +package equipment + +import ( + "errors" + "math" +) + +type Service struct { + repo *Repository +} + +func NewService() *Service { + return &Service{ + repo: NewRepository(), + } +} + +// Equipment methods +func (s *Service) CreateEquipment(userID uint, name, equipmentType, brand, model string, weight float64, notes string) (*Equipment, error) { + if name == "" || equipmentType == "" { + return nil, errors.New("name and type are required") + } + + equipment := &Equipment{ + UserID: userID, + Name: name, + Type: equipmentType, + Brand: brand, + Model: model, + Weight: weight, + Notes: notes, + Active: true, + } + + if err := s.repo.CreateEquipment(equipment); err != nil { + return nil, err + } + + return equipment, nil +} + +func (s *Service) GetUserEquipment(userID uint) ([]Equipment, error) { + return s.repo.GetUserEquipment(userID) +} + +func (s *Service) GetEquipmentByID(id, userID uint) (*Equipment, error) { + return s.repo.GetEquipmentByID(id, userID) +} + +func (s *Service) UpdateEquipment(id, userID uint, updates map[string]interface{}) (*Equipment, error) { + equipment, err := s.repo.GetEquipmentByID(id, userID) + if err != nil { + return nil, err + } + + if name, ok := updates["name"].(string); ok && name != "" { + equipment.Name = name + } + if brand, ok := updates["brand"].(string); ok { + equipment.Brand = brand + } + if model, ok := updates["model"].(string); ok { + equipment.Model = model + } + if weight, ok := updates["weight"].(float64); ok { + equipment.Weight = weight + } + if notes, ok := updates["notes"].(string); ok { + equipment.Notes = notes + } + if active, ok := updates["active"].(bool); ok { + equipment.Active = active + } + + if err := s.repo.UpdateEquipment(equipment); err != nil { + return nil, err + } + + return equipment, nil +} + +func (s *Service) DeleteEquipment(id, userID uint) error { + return s.repo.DeleteEquipment(id, userID) +} + +// Training Zones calculation +func (s *Service) CalculateHRZones(maxHR, restingHR int) *HRZones { + if maxHR <= 0 { + return nil + } + + // Karvonen formula (Heart Rate Reserve) + hrr := maxHR - restingHR + + return &HRZones{ + Zone1: TrainingZone{ + ID: 1, + Name: "Recovery", + Min: restingHR + int(math.Round(float64(hrr)*0.50)), + Max: restingHR + int(math.Round(float64(hrr)*0.60)), + Color: "#4285F4", // Blue + }, + Zone2: TrainingZone{ + ID: 2, + Name: "Endurance", + Min: restingHR + int(math.Round(float64(hrr)*0.60)), + Max: restingHR + int(math.Round(float64(hrr)*0.70)), + Color: "#34A853", // Green + }, + Zone3: TrainingZone{ + ID: 3, + Name: "Tempo", + Min: restingHR + int(math.Round(float64(hrr)*0.70)), + Max: restingHR + int(math.Round(float64(hrr)*0.80)), + Color: "#FBBC04", // Yellow + }, + Zone4: TrainingZone{ + ID: 4, + Name: "Threshold", + Min: restingHR + int(math.Round(float64(hrr)*0.80)), + Max: restingHR + int(math.Round(float64(hrr)*0.90)), + Color: "#EA4335", // Red + }, + Zone5: TrainingZone{ + ID: 5, + Name: "VO2 Max", + Min: restingHR + int(math.Round(float64(hrr)*0.90)), + Max: maxHR, + Color: "#A61C00", // Dark Red + }, + } +} + +func (s *Service) CalculatePowerZones(ftp int) *PowerZones { + if ftp <= 0 { + return nil + } + + return &PowerZones{ + Zone1: TrainingZone{ + ID: 1, + Name: "Active Recovery", + Min: 0, + Max: int(math.Round(float64(ftp) * 0.55)), + Color: "#4285F4", // Blue + }, + Zone2: TrainingZone{ + ID: 2, + Name: "Endurance", + Min: int(math.Round(float64(ftp) * 0.55)), + Max: int(math.Round(float64(ftp) * 0.75)), + Color: "#34A853", // Green + }, + Zone3: TrainingZone{ + ID: 3, + Name: "Sweet Spot", + Min: int(math.Round(float64(ftp) * 0.75)), + Max: int(math.Round(float64(ftp) * 0.90)), + Color: "#FBBC04", // Yellow + }, + Zone4: TrainingZone{ + ID: 4, + Name: "Threshold", + Min: int(math.Round(float64(ftp) * 0.90)), + Max: int(math.Round(float64(ftp) * 1.05)), + Color: "#EA4335", // Red + }, + Zone5: TrainingZone{ + ID: 5, + Name: "VO2 Max", + Min: int(math.Round(float64(ftp) * 1.05)), + Max: int(math.Round(float64(ftp) * 1.20)), + Color: "#A61C00", // Dark Red + }, + Zone6: TrainingZone{ + ID: 6, + Name: "Anaerobic", + Min: int(math.Round(float64(ftp) * 1.20)), + Max: int(math.Round(float64(ftp) * 1.50)), + Color: "#800080", // Purple + }, + Zone7: TrainingZone{ + ID: 7, + Name: "Neuromuscular", + Min: int(math.Round(float64(ftp) * 1.50)), + Max: int(math.Round(float64(ftp) * 2.00)), + Color: "#FF1744", // Bright Red + }, + } +} \ No newline at end of file diff --git a/internal/user/handler.go b/internal/user/handler.go index e70e96d..fb17051 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -2,6 +2,7 @@ package user import ( "encoding/json" + "log" "net/http" "rideaware/internal/config" @@ -26,7 +27,7 @@ type GetProfileResponse struct { func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims) - user, err := h.service.repo.GetUserByID(claims.UserID) + user, err := h.service.GetUserByID(claims.UserID) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) @@ -34,6 +35,8 @@ func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { return } + log.Printf("DEBUG GetProfile: User ID=%d, Profile=%+v", user.ID, user.Profile) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(GetProfileResponse{ User: user, @@ -50,6 +53,7 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { Bio string `json:"bio"` FTP int `json:"ftp"` MaxHR int `json:"max_hr"` + RestingHR int `json:"resting_hr"` Weight float64 `json:"weight"` } @@ -60,7 +64,7 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { return } - user, err := h.service.repo.GetUserByID(claims.UserID) + user, err := h.service.GetUserByID(claims.UserID) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) @@ -68,23 +72,39 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { return } - // Update profile + log.Printf("DEBUG UpdateProfile: Before - Profile=%+v", user.Profile) + if user.Profile != nil { user.Profile.FirstName = req.FirstName user.Profile.LastName = req.LastName user.Profile.Bio = req.Bio user.Profile.FTP = req.FTP user.Profile.MaxHR = req.MaxHR + user.Profile.RestingHR = req.RestingHR user.Profile.Weight = req.Weight - if err := h.service.repo.UpdateUser(user); err != nil { + log.Printf("DEBUG UpdateProfile: After - Profile=%+v", user.Profile) + + if err := h.service.UpdateUser(user); err != nil { + log.Printf("DEBUG UpdateProfile: Error updating - %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": "failed to update profile"}) return } + + user, err = h.service.GetUserByID(claims.UserID) + if err != nil { + log.Printf("DEBUG UpdateProfile: Error reloading - %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "failed to load profile"}) + return + } } + log.Printf("DEBUG UpdateProfile: Final - Profile=%+v", user.Profile) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(GetProfileResponse{ User: user, diff --git a/internal/user/model.go b/internal/user/model.go index e69856d..8ca9950 100644 --- a/internal/user/model.go +++ b/internal/user/model.go @@ -17,14 +17,14 @@ type User struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Profile *Profile `gorm:"foreignKey:UserID;constraint:OnDelete:Cascade" json:"profile,omitempty"` - PasswordResets []PasswordReset `gorm:"foreignKey:UserID;constraint:OnDelete:Cascade" json:"password_resets,omitempty"` - Sessions []Session `gorm:"foreignKey:UserID;constraint:OnDelete:Cascade" json:"sessions,omitempty"` + Profile *Profile `gorm:"foreignKey:UserID;references:ID" json:"profile,omitempty"` + PasswordResets []PasswordReset `gorm:"foreignKey:UserID;references:ID" json:"password_resets,omitempty"` + Sessions []Session `gorm:"foreignKey:UserID;references:ID" json:"sessions,omitempty"` } type Profile struct { ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"not null;uniqueIndex" json:"user_id"` + UserID uint `gorm:"not null;uniqueIndex;index" json:"user_id"` FirstName string `gorm:"default:''" json:"first_name"` LastName string `gorm:"default:''" json:"last_name"` Bio string `gorm:"default:''" json:"bio"` @@ -40,9 +40,14 @@ type Profile struct { UpdatedAt time.Time `json:"updated_at"` } +// Specify the table name +func (Profile) TableName() string { + return "user_profiles" +} + type PasswordReset struct { ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"not null" json:"user_id"` + UserID uint `gorm:"not null;index" json:"user_id"` Token string `gorm:"uniqueIndex;not null" json:"token"` ExpiresAt time.Time `gorm:"not null" json:"expires_at"` UsedAt *time.Time `json:"used_at"` @@ -62,7 +67,6 @@ type Session struct { // ===== Methods ===== -// SetPassword hashes and sets the password func (u *User) SetPassword(rawPassword string) error { if len(rawPassword) < 8 { return errors.New("password must be at least 8 characters long") @@ -78,7 +82,6 @@ func (u *User) SetPassword(rawPassword string) error { return nil } -// CheckPassword verifies the password func (u *User) CheckPassword(password string) bool { return bcrypt.CompareHashAndPassword( []byte(u.Password), @@ -86,7 +89,6 @@ func (u *User) CheckPassword(password string) bool { ) == nil } -// AfterCreate hook: automatically create profile after user insert func (u *User) AfterCreate(tx *gorm.DB) error { profile := &Profile{ UserID: u.ID, @@ -94,12 +96,10 @@ func (u *User) AfterCreate(tx *gorm.DB) error { return tx.Create(profile).Error } -// IsPasswordResetTokenValid checks if token exists and is not expired func (prt *PasswordReset) IsValid() bool { return prt.UsedAt == nil && time.Now().Before(prt.ExpiresAt) } -// IsSessionValid checks if session is not expired func (s *Session) IsValid() bool { return time.Now().Before(s.ExpiresAt) } \ No newline at end of file diff --git a/internal/user/repository.go b/internal/user/repository.go index 41f34ca..00405f6 100644 --- a/internal/user/repository.go +++ b/internal/user/repository.go @@ -2,6 +2,7 @@ package user import ( "errors" + "log" "rideaware/pkg/database" "gorm.io/gorm" ) @@ -40,17 +41,45 @@ func (r *Repository) GetUserByEmail(email string) (*User, error) { func (r *Repository) GetUserByID(id uint) (*User, error) { var user User - if err := database.DB.Preload("Profile").Where("id = ?", id).First(&user).Error; err != nil { + + // Get the user + if err := database.DB.Where("id = ?", id).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("user not found") } return nil, err } + + // Manually load the profile + var profile Profile + if err := database.DB.Where("user_id = ?", id).First(&profile).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + log.Printf("Error loading profile: %v", err) + } + // Profile might not exist, that's okay + } else { + user.Profile = &profile + } + + log.Printf("DEBUG: Loaded user %d, profile ID=%d, profile=%+v", id, profile.ID, user.Profile) + return &user, nil } func (r *Repository) UpdateUser(user *User) error { - return database.DB.Save(user).Error + // Update the user + if err := database.DB.Model(user).Updates(user).Error; err != nil { + return err + } + + // Update the profile if it exists + if user.Profile != nil { + if err := database.DB.Model(user.Profile).Updates(user.Profile).Error; err != nil { + return err + } + } + + return nil } func (r *Repository) UserExists(username, email string) (bool, error) { diff --git a/internal/user/service.go b/internal/user/service.go index 3e61607..e6d42b9 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -144,6 +144,16 @@ func (s *Service) ResetPassword(token, newPassword string) error { return tx.Commit().Error } +// GetUserByID retrieves a user with their profile +func (s *Service) GetUserByID(userID uint) (*User, error) { + return s.repo.GetUserByID(userID) +} + +// UpdateUser saves user changes +func (s *Service) UpdateUser(user *User) error { + return s.repo.UpdateUser(user) +} + // Helper functions func isValidEmail(email string) bool { regex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)