diff --git a/.clinerules b/.clinerules index c6d9dde..19d376d 100644 --- a/.clinerules +++ b/.clinerules @@ -34,6 +34,14 @@ SOLVE TASKS AS FAST AS POSSIBLE. EACH REQUEST COSTS THE USER MONEY. - make run: Start the development server 9. CUSTOM RULES - Add custom rules to .clinerules if: - - Unexpected behavior is encountered - - Specific conditions require warnings - - New patterns emerge that need documentation \ No newline at end of file +- Unexpected behavior is encountered +- Specific conditions require warnings +- New patterns emerge that need documentation +10.Implement a REST API update handling in Go using Gin that ensures the following behavior: +- The update request is received as JSON. +- If a field is present in the JSON and set to null, the corresponding value in the database should be removed. +- If a field is missing in the JSON, it should not be modified. +- If a field is present in the JSON and not null, it should be updated. +- Use either a struct or a map to handle the JSON data. +- Ensure the update logic is robust and does not unintentionally remove or overwrite fields. +- Optional: Handle error cases like invalid JSON and return appropriate HTTP status codes. \ No newline at end of file diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index 5736613..2c1c93d 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -70,7 +70,7 @@ func seedDatabase(ctx context.Context) error { adminUser := models.User{ Email: "admin@example.com", Role: models.RoleAdmin, - CompanyID: defaultCompany.ID, + CompanyID: &defaultCompany.ID, HourlyRate: 100.0, } diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index 773515d..ebd94c8 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -163,11 +163,38 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { // Set ID from URL userUpdateDTO.ID = id.String() - // Convert DTO to model - userUpdate := convertUpdateDTOToModel(userUpdateDTO) + // Set ID from URL + userUpdateDTO.ID = id.String() + + // Convert DTO to Model + idWrapper := models.FromULID(id) + update := models.UserUpdate{ + ID: idWrapper, + } + + if userUpdateDTO.Email != nil { + update.Email = userUpdateDTO.Email + } + if userUpdateDTO.Password != nil { + update.Password = userUpdateDTO.Password + } + if userUpdateDTO.Role != nil { + update.Role = userUpdateDTO.Role + } + if userUpdateDTO.CompanyID != nil { + companyID, err := models.ULIDWrapperFromString(*userUpdateDTO.CompanyID) + if err != nil { + utils.BadRequestResponse(c, "Invalid company ID format") + return + } + update.CompanyID = &companyID + } + if userUpdateDTO.HourlyRate != nil { + update.HourlyRate = userUpdateDTO.HourlyRate + } // Update user in the database - user, err := models.UpdateUser(c.Request.Context(), userUpdate) + user, err := models.UpdateUser(c.Request.Context(), update) if err != nil { utils.InternalErrorResponse(c, "Error updating user: "+err.Error()) return @@ -348,20 +375,27 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) { // Helper functions for DTO conversion func convertUserToDTO(user *models.User) dto.UserDto { + var companyID *string + if user.CompanyID != nil { + s := user.CompanyID.String() + companyID = &s + } return dto.UserDto{ ID: user.ID.String(), CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, Email: user.Email, Role: user.Role, - CompanyID: user.CompanyID.String(), + CompanyID: companyID, HourlyRate: user.HourlyRate, } } func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate { - // Convert CompanyID from int to ULID (this is a simplification, adjust as needed) - companyID, _ := models.ULIDWrapperFromString(dto.CompanyID) + var companyID models.ULIDWrapper + if dto.CompanyID != nil { + companyID, _ = models.ULIDWrapperFromString(*dto.CompanyID) // Ignoring error, validation happens in the model + } return models.UserCreate{ Email: dto.Email, @@ -371,34 +405,3 @@ func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate { HourlyRate: dto.HourlyRate, } } - -func convertUpdateDTOToModel(dto dto.UserUpdateDto) models.UserUpdate { - id, _ := ulid.Parse(dto.ID) - update := models.UserUpdate{ - ID: models.FromULID(id), - } - - if dto.Email != nil { - update.Email = dto.Email - } - - if dto.Password != nil { - update.Password = dto.Password - } - - if dto.Role != nil { - update.Role = dto.Role - } - - if dto.CompanyID != nil { - // Convert CompanyID from int to ULID (this is a simplification, adjust as needed) - companyID, _ := models.ULIDWrapperFromString(*dto.CompanyID) - update.CompanyID = &companyID - } - - if dto.HourlyRate != nil { - update.HourlyRate = dto.HourlyRate - } - - return update -} diff --git a/backend/internal/dtos/common.go b/backend/internal/dtos/common.go new file mode 100644 index 0000000..6dd0eec --- /dev/null +++ b/backend/internal/dtos/common.go @@ -0,0 +1,33 @@ +package dto + +import "encoding/json" + +type NullString struct { + String string + IsNull bool +} + +// Serialization +func (ns NullString) MarshalJSON() ([]byte, error) { + // If Valid is true, return the JSON serialization result of String. + if ns.IsNull { + return []byte(`"` + ns.String + `"`), nil + } + // If Valid is false, return the serialization result of null. + return []byte("null"), nil +} + +// Deserialization +func (ns *NullString) UnmarshalJSON(data []byte) error { + // If data is null, set Valid to false and String to an empty string. + if string(data) == "null" { + ns.String, ns.IsNull = "", false + return nil + } + // Otherwise, deserialize data to String and set Valid to true. + if err := json.Unmarshal(data, &ns.String); err != nil { + return err + } + ns.IsNull = true + return nil +} diff --git a/backend/internal/dtos/user_dto.go b/backend/internal/dtos/user_dto.go index 072970e..ae61c83 100644 --- a/backend/internal/dtos/user_dto.go +++ b/backend/internal/dtos/user_dto.go @@ -11,7 +11,7 @@ type UserDto struct { LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"` Email string `json:"email" example:"test@example.com"` Role string `json:"role" example:"admin"` - CompanyID string `json:"companyId" example:"01HGW2BBG0000000000000000"` + CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"` HourlyRate float64 `json:"hourlyRate" example:"50.00"` } @@ -19,7 +19,7 @@ type UserCreateDto struct { Email string `json:"email" example:"test@example.com"` Password string `json:"password" example:"password123"` Role string `json:"role" example:"admin"` - CompanyID string `json:"companyId" example:"01HGW2BBG0000000000000000"` + CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"` HourlyRate float64 `json:"hourlyRate" example:"50.00"` } diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 57c9d85..4ba878d 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -35,12 +35,12 @@ const ( // User represents a user in the system type User struct { EntityBase - Email string `gorm:"column:email;unique;not null"` - Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Base64-encoded Salt - Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash - Role string `gorm:"column:role;not null;default:'user'"` - CompanyID ULIDWrapper `gorm:"column:company_id;type:bytea;not null;index"` - HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"` + Email string `gorm:"column:email;unique;not null"` + Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Base64-encoded Salt + Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash + Role string `gorm:"column:role;not null;default:'user'"` + CompanyID *ULIDWrapper `gorm:"column:company_id;type:bytea;index"` + HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"` // Relationship for Eager Loading Company *Company `gorm:"foreignKey:CompanyID"` @@ -335,10 +335,22 @@ func GetAllUsers(ctx context.Context) ([]User, error) { return users, nil } +// getCompanyCondition builds the company condition for queries +func getCompanyCondition(companyID *ULIDWrapper) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + if companyID == nil { + return db.Where("company_id IS NULL") + } + return db.Where("company_id = ?", *companyID) + } +} + // GetUsersByCompanyID returns all users of a company func GetUsersByCompanyID(ctx context.Context, companyID ULIDWrapper) ([]User, error) { var users []User - result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&users) + // Apply the dynamic company condition + condition := getCompanyCondition(&companyID) + result := GetEngine(ctx).Scopes(condition).Find(&users) if result.Error != nil { return nil, result.Error } @@ -386,7 +398,7 @@ func CreateUser(ctx context.Context, create UserCreate) (*User, error) { Salt: pwData.Salt, Hash: pwData.Hash, Role: create.Role, - CompanyID: create.CompanyID, + CompanyID: &create.CompanyID, HourlyRate: create.HourlyRate, } @@ -435,7 +447,7 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) { } // If CompanyID is updated, check if it exists - if update.CompanyID != nil && update.CompanyID.Compare(user.CompanyID) != 0 { + if update.CompanyID != nil && (user.CompanyID == nil || update.CompanyID.Compare(*user.CompanyID) != 0) { var companyCount int64 if err := tx.Model(&Company{}).Where("id = ?", *update.CompanyID).Count(&companyCount).Error; err != nil { return fmt.Errorf("error checking company: %w", err) diff --git a/backend/tygo.yaml b/backend/tygo.yaml index 0bc6aa2..43d2d55 100644 --- a/backend/tygo.yaml +++ b/backend/tygo.yaml @@ -3,4 +3,5 @@ packages: type_mappings: "time.Time": "string" "ulid.ULID": "string" + "null.String": "null | string" output_path: ../frontend/src/types/dto.ts diff --git a/frontend/src/types/dto.ts b/frontend/src/types/dto.ts index 044b839..bb63d59 100644 --- a/frontend/src/types/dto.ts +++ b/frontend/src/types/dto.ts @@ -160,14 +160,14 @@ export interface UserDto { lastEditorID: string; email: string; role: string; - companyId: string; + companyId?: string; hourlyRate: number /* float64 */; } export interface UserCreateDto { email: string; password: string; role: string; - companyId: string; + companyId?: string; hourlyRate: number /* float64 */; } export interface UserUpdateDto {