feat: Enhance user update handling and introduce NullString type for optional fields

This commit is contained in:
Jean Jacques Avril 2025-03-12 07:54:00 +00:00
parent 0379ea4ae4
commit da115dc3f6
8 changed files with 111 additions and 54 deletions

View File

@ -34,6 +34,14 @@ SOLVE TASKS AS FAST AS POSSIBLE. EACH REQUEST COSTS THE USER MONEY.
- make run: Start the development server - make run: Start the development server
9. CUSTOM RULES 9. CUSTOM RULES
- Add custom rules to .clinerules if: - Add custom rules to .clinerules if:
- Unexpected behavior is encountered - Unexpected behavior is encountered
- Specific conditions require warnings - Specific conditions require warnings
- New patterns emerge that need documentation - 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.

View File

@ -70,7 +70,7 @@ func seedDatabase(ctx context.Context) error {
adminUser := models.User{ adminUser := models.User{
Email: "admin@example.com", Email: "admin@example.com",
Role: models.RoleAdmin, Role: models.RoleAdmin,
CompanyID: defaultCompany.ID, CompanyID: &defaultCompany.ID,
HourlyRate: 100.0, HourlyRate: 100.0,
} }

View File

@ -163,11 +163,38 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
// Set ID from URL // Set ID from URL
userUpdateDTO.ID = id.String() userUpdateDTO.ID = id.String()
// Convert DTO to model // Set ID from URL
userUpdate := convertUpdateDTOToModel(userUpdateDTO) 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 // Update user in the database
user, err := models.UpdateUser(c.Request.Context(), userUpdate) user, err := models.UpdateUser(c.Request.Context(), update)
if err != nil { if err != nil {
utils.InternalErrorResponse(c, "Error updating user: "+err.Error()) utils.InternalErrorResponse(c, "Error updating user: "+err.Error())
return return
@ -348,20 +375,27 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) {
// Helper functions for DTO conversion // Helper functions for DTO conversion
func convertUserToDTO(user *models.User) dto.UserDto { func convertUserToDTO(user *models.User) dto.UserDto {
var companyID *string
if user.CompanyID != nil {
s := user.CompanyID.String()
companyID = &s
}
return dto.UserDto{ return dto.UserDto{
ID: user.ID.String(), ID: user.ID.String(),
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt, UpdatedAt: user.UpdatedAt,
Email: user.Email, Email: user.Email,
Role: user.Role, Role: user.Role,
CompanyID: user.CompanyID.String(), CompanyID: companyID,
HourlyRate: user.HourlyRate, HourlyRate: user.HourlyRate,
} }
} }
func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate { func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate {
// Convert CompanyID from int to ULID (this is a simplification, adjust as needed) var companyID models.ULIDWrapper
companyID, _ := models.ULIDWrapperFromString(dto.CompanyID) if dto.CompanyID != nil {
companyID, _ = models.ULIDWrapperFromString(*dto.CompanyID) // Ignoring error, validation happens in the model
}
return models.UserCreate{ return models.UserCreate{
Email: dto.Email, Email: dto.Email,
@ -371,34 +405,3 @@ func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate {
HourlyRate: dto.HourlyRate, 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
}

View File

@ -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
}

View File

@ -11,7 +11,7 @@ type UserDto struct {
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"` LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
Email string `json:"email" example:"test@example.com"` Email string `json:"email" example:"test@example.com"`
Role string `json:"role" example:"admin"` 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"` HourlyRate float64 `json:"hourlyRate" example:"50.00"`
} }
@ -19,7 +19,7 @@ type UserCreateDto struct {
Email string `json:"email" example:"test@example.com"` Email string `json:"email" example:"test@example.com"`
Password string `json:"password" example:"password123"` Password string `json:"password" example:"password123"`
Role string `json:"role" example:"admin"` 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"` HourlyRate float64 `json:"hourlyRate" example:"50.00"`
} }

View File

@ -39,7 +39,7 @@ type User struct {
Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Base64-encoded Salt 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 Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash
Role string `gorm:"column:role;not null;default:'user'"` Role string `gorm:"column:role;not null;default:'user'"`
CompanyID ULIDWrapper `gorm:"column:company_id;type:bytea;not null;index"` CompanyID *ULIDWrapper `gorm:"column:company_id;type:bytea;index"`
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"` HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
// Relationship for Eager Loading // Relationship for Eager Loading
@ -335,10 +335,22 @@ func GetAllUsers(ctx context.Context) ([]User, error) {
return users, nil 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 // GetUsersByCompanyID returns all users of a company
func GetUsersByCompanyID(ctx context.Context, companyID ULIDWrapper) ([]User, error) { func GetUsersByCompanyID(ctx context.Context, companyID ULIDWrapper) ([]User, error) {
var users []User 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 { if result.Error != nil {
return nil, result.Error return nil, result.Error
} }
@ -386,7 +398,7 @@ func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
Salt: pwData.Salt, Salt: pwData.Salt,
Hash: pwData.Hash, Hash: pwData.Hash,
Role: create.Role, Role: create.Role,
CompanyID: create.CompanyID, CompanyID: &create.CompanyID,
HourlyRate: create.HourlyRate, 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 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 var companyCount int64
if err := tx.Model(&Company{}).Where("id = ?", *update.CompanyID).Count(&companyCount).Error; err != nil { if err := tx.Model(&Company{}).Where("id = ?", *update.CompanyID).Count(&companyCount).Error; err != nil {
return fmt.Errorf("error checking company: %w", err) return fmt.Errorf("error checking company: %w", err)

View File

@ -3,4 +3,5 @@ packages:
type_mappings: type_mappings:
"time.Time": "string" "time.Time": "string"
"ulid.ULID": "string" "ulid.ULID": "string"
"null.String": "null | string"
output_path: ../frontend/src/types/dto.ts output_path: ../frontend/src/types/dto.ts

View File

@ -160,14 +160,14 @@ export interface UserDto {
lastEditorID: string; lastEditorID: string;
email: string; email: string;
role: string; role: string;
companyId: string; companyId?: string;
hourlyRate: number /* float64 */; hourlyRate: number /* float64 */;
} }
export interface UserCreateDto { export interface UserCreateDto {
email: string; email: string;
password: string; password: string;
role: string; role: string;
companyId: string; companyId?: string;
hourlyRate: number /* float64 */; hourlyRate: number /* float64 */;
} }
export interface UserUpdateDto { export interface UserUpdateDto {