From 4170eb5fbd4fd5b80ef303c8a7832bf75262ace5 Mon Sep 17 00:00:00 2001 From: Jean Jacques Avril Date: Wed, 12 Mar 2025 09:32:29 +0000 Subject: [PATCH] feat: Refactor DTOs to use types.ULID and update companyId fields to be optional --- .../internal/api/handlers/activity_handler.go | 7 +- .../internal/api/handlers/company_handler.go | 7 +- .../internal/api/handlers/customer_handler.go | 45 +++++++---- .../internal/api/handlers/project_handler.go | 9 ++- .../api/handlers/timeentry_handler.go | 25 +++--- backend/internal/api/handlers/user_handler.go | 22 +++--- backend/internal/api/middleware/jwt_auth.go | 21 ++--- backend/internal/dtos/customer_dto.go | 22 +++--- backend/internal/dtos/helper/nullstring.go | 33 -------- backend/internal/dtos/user_dto.go | 10 +-- backend/internal/models/activity.go | 11 +-- backend/internal/models/base.go | 9 ++- backend/internal/models/company.go | 9 ++- backend/internal/models/customer.go | 22 +++--- backend/internal/models/project.go | 17 ++-- backend/internal/models/timeentry.go | 57 +++++++------- backend/internal/models/ulid_extension.go | 77 ------------------- backend/internal/models/user.go | 41 +++++----- backend/internal/types/wrapped-uild.go | 77 +++++++++++++++++++ backend/tygo.yaml | 2 +- frontend/src/types/dto.ts | 10 ++- 21 files changed, 269 insertions(+), 264 deletions(-) delete mode 100644 backend/internal/dtos/helper/nullstring.go delete mode 100644 backend/internal/models/ulid_extension.go create mode 100644 backend/internal/types/wrapped-uild.go diff --git a/backend/internal/api/handlers/activity_handler.go b/backend/internal/api/handlers/activity_handler.go index 74739c5..bd4670a 100644 --- a/backend/internal/api/handlers/activity_handler.go +++ b/backend/internal/api/handlers/activity_handler.go @@ -8,6 +8,7 @@ import ( "github.com/timetracker/backend/internal/api/utils" dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" + "github.com/timetracker/backend/internal/types" ) // ActivityHandler handles activity-related API endpoints @@ -72,7 +73,7 @@ func (h *ActivityHandler) GetActivityByID(c *gin.Context) { } // Get activity from the database - activity, err := models.GetActivityByID(c.Request.Context(), models.FromULID(id)) + activity, err := models.GetActivityByID(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving activity: "+err.Error()) return @@ -207,7 +208,7 @@ func (h *ActivityHandler) DeleteActivity(c *gin.Context) { } // Delete activity from the database - err = models.DeleteActivity(c.Request.Context(), models.FromULID(id)) + err = models.DeleteActivity(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting activity: "+err.Error()) return @@ -238,7 +239,7 @@ func convertCreateActivityDTOToModel(dto dto.ActivityCreateDto) models.ActivityC func convertUpdateActivityDTOToModel(dto dto.ActivityUpdateDto) models.ActivityUpdate { id, _ := ulid.Parse(dto.ID) update := models.ActivityUpdate{ - ID: models.FromULID(id), + ID: types.FromULID(id), } if dto.Name != nil { diff --git a/backend/internal/api/handlers/company_handler.go b/backend/internal/api/handlers/company_handler.go index a07ab29..d55758c 100644 --- a/backend/internal/api/handlers/company_handler.go +++ b/backend/internal/api/handlers/company_handler.go @@ -8,6 +8,7 @@ import ( "github.com/timetracker/backend/internal/api/utils" dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" + "github.com/timetracker/backend/internal/types" ) // CompanyHandler handles company-related API endpoints @@ -72,7 +73,7 @@ func (h *CompanyHandler) GetCompanyByID(c *gin.Context) { } // Get company from the database - company, err := models.GetCompanyByID(c.Request.Context(), models.FromULID(id)) + company, err := models.GetCompanyByID(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving company: "+err.Error()) return @@ -207,7 +208,7 @@ func (h *CompanyHandler) DeleteCompany(c *gin.Context) { } // Delete company from the database - err = models.DeleteCompany(c.Request.Context(), models.FromULID(id)) + err = models.DeleteCompany(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting company: "+err.Error()) return @@ -236,7 +237,7 @@ func convertCreateCompanyDTOToModel(dto dto.CompanyCreateDto) models.CompanyCrea func convertUpdateCompanyDTOToModel(dto dto.CompanyUpdateDto) models.CompanyUpdate { id, _ := ulid.Parse(dto.ID) update := models.CompanyUpdate{ - ID: models.FromULID(id), + ID: types.FromULID(id), } if dto.Name != nil { diff --git a/backend/internal/api/handlers/customer_handler.go b/backend/internal/api/handlers/customer_handler.go index 4b47d2e..36bf4d1 100644 --- a/backend/internal/api/handlers/customer_handler.go +++ b/backend/internal/api/handlers/customer_handler.go @@ -6,9 +6,11 @@ import ( "github.com/gin-gonic/gin" "github.com/oklog/ulid/v2" + "github.com/timetracker/backend/internal/api/middleware" "github.com/timetracker/backend/internal/api/utils" dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" + "github.com/timetracker/backend/internal/types" ) // CustomerHandler handles customer-related API endpoints @@ -73,7 +75,7 @@ func (h *CustomerHandler) GetCustomerByID(c *gin.Context) { } // Get customer from the database - customer, err := models.GetCustomerByID(c.Request.Context(), models.FromULID(id)) + customer, err := models.GetCustomerByID(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving customer: "+err.Error()) return @@ -144,6 +146,11 @@ func (h *CustomerHandler) GetCustomersByCompanyID(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /customers [post] func (h *CustomerHandler) CreateCustomer(c *gin.Context) { + userID, err := middleware.GetUserIDFromContext(c) + if err != nil { + utils.UnauthorizedResponse(c, "User not authenticated") + return + } // Parse request body var customerCreateDTO dto.CustomerCreateDto if err := c.ShouldBindJSON(&customerCreateDTO); err != nil { @@ -157,6 +164,7 @@ func (h *CustomerHandler) CreateCustomer(c *gin.Context) { utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) return } + customerCreate.OwnerUserID = &userID // Create customer in the database customer, err := models.CreateCustomer(c.Request.Context(), customerCreate) @@ -255,7 +263,7 @@ func (h *CustomerHandler) DeleteCustomer(c *gin.Context) { } // Delete customer from the database - err = models.DeleteCustomer(c.Request.Context(), models.FromULID(id)) + err = models.DeleteCustomer(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting customer: "+err.Error()) return @@ -267,22 +275,29 @@ func (h *CustomerHandler) DeleteCustomer(c *gin.Context) { // Helper functions for DTO conversion func convertCustomerToDTO(customer *models.Customer) dto.CustomerDto { + var companyID *string + if customer.CompanyID != nil { + s := customer.CompanyID.String() + companyID = &s + } return dto.CustomerDto{ ID: customer.ID.String(), CreatedAt: customer.CreatedAt, UpdatedAt: customer.UpdatedAt, Name: customer.Name, - CompanyID: customer.CompanyID.String(), + CompanyID: companyID, } } func convertCreateCustomerDTOToModel(dto dto.CustomerCreateDto) (models.CustomerCreate, error) { - - companyID, err := models.ULIDWrapperFromString(dto.CompanyID) - if err != nil { - return models.CustomerCreate{}, fmt.Errorf("invalid company ID: %w", err) + var companyID *types.ULID + if dto.CompanyID != nil { + wrapper, err := types.ULIDFromString(*dto.CompanyID) // Ignoring error, validation happens in the model + if err != nil { + return models.CustomerCreate{}, fmt.Errorf("invalid company ID: %w", err) + } + companyID = &wrapper } - create := models.CustomerCreate{ Name: dto.Name, CompanyID: companyID, @@ -291,7 +306,7 @@ func convertCreateCustomerDTOToModel(dto dto.CustomerCreateDto) (models.Customer } func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) (models.CustomerUpdate, error) { - id, err := models.ULIDWrapperFromString(dto.ID) + id, err := types.ULIDFromString(dto.ID) if err != nil { return models.CustomerUpdate{}, fmt.Errorf("invalid customer ID: %w", err) } @@ -305,11 +320,15 @@ func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) (models.Customer } if dto.CompanyID != nil { - companyID, err := models.ULIDWrapperFromString(*dto.CompanyID) - if err != nil { - return models.CustomerUpdate{}, fmt.Errorf("invalid company ID: %w", err) + if dto.CompanyID.Valid { + companyID, err := types.ULIDFromString(*dto.CompanyID.Value) + if err != nil { + return models.CustomerUpdate{}, fmt.Errorf("invalid company ID: %w", err) + } + update.CompanyID = &companyID + } else { + update.CompanyID = nil } - update.CompanyID = &companyID } return update, nil diff --git a/backend/internal/api/handlers/project_handler.go b/backend/internal/api/handlers/project_handler.go index d8c52b5..e98e41e 100644 --- a/backend/internal/api/handlers/project_handler.go +++ b/backend/internal/api/handlers/project_handler.go @@ -9,6 +9,7 @@ import ( "github.com/timetracker/backend/internal/api/utils" dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" + "github.com/timetracker/backend/internal/types" ) // ProjectHandler handles project-related API endpoints @@ -102,7 +103,7 @@ func (h *ProjectHandler) GetProjectByID(c *gin.Context) { } // Get project from the database - project, err := models.GetProjectByID(c.Request.Context(), models.FromULID(id)) + project, err := models.GetProjectByID(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving project: "+err.Error()) return @@ -308,7 +309,7 @@ func convertProjectToDTO(project *models.Project) dto.ProjectDto { func convertCreateProjectDTOToModel(dto dto.ProjectCreateDto) (models.ProjectCreate, error) { // Convert CustomerID from int to ULID (this is a simplification, adjust as needed) - customerID, err := models.ULIDWrapperFromString(dto.CustomerID) + customerID, err := types.ULIDFromString(dto.CustomerID) if err != nil { return models.ProjectCreate{}, fmt.Errorf("invalid customer ID: %w", err) } @@ -325,7 +326,7 @@ func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpd return models.ProjectUpdate{}, fmt.Errorf("invalid project ID: %w", err) } update := models.ProjectUpdate{ - ID: models.FromULID(id), + ID: types.FromULID(id), } if dto.Name != nil { @@ -334,7 +335,7 @@ func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpd if dto.CustomerID != nil { // Convert CustomerID from int to ULID (this is a simplification, adjust as needed) - customerID, err := models.ULIDWrapperFromString(*dto.CustomerID) + customerID, err := types.ULIDFromString(*dto.CustomerID) if err != nil { return models.ProjectUpdate{}, fmt.Errorf("invalid customer ID: %w", err) } diff --git a/backend/internal/api/handlers/timeentry_handler.go b/backend/internal/api/handlers/timeentry_handler.go index c7556f0..2f4ae1d 100644 --- a/backend/internal/api/handlers/timeentry_handler.go +++ b/backend/internal/api/handlers/timeentry_handler.go @@ -11,6 +11,7 @@ import ( "github.com/timetracker/backend/internal/api/utils" dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" + "github.com/timetracker/backend/internal/types" ) // TimeEntryHandler handles time entry-related API endpoints @@ -75,7 +76,7 @@ func (h *TimeEntryHandler) GetTimeEntryByID(c *gin.Context) { } // Get time entry from the database - timeEntry, err := models.GetTimeEntryByID(c.Request.Context(), models.FromULID(id)) + timeEntry, err := models.GetTimeEntryByID(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving time entry: "+err.Error()) return @@ -116,7 +117,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByUserID(c *gin.Context) { } // Get time entries from the database - timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), models.FromULID(userID)) + timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), types.FromULID(userID)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) return @@ -152,7 +153,7 @@ func (h *TimeEntryHandler) GetMyTimeEntries(c *gin.Context) { } // Get time entries from the database - timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), models.FromULID(userID)) + timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), userID) if err != nil { utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) return @@ -191,7 +192,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByProjectID(c *gin.Context) { } // Get time entries from the database - timeEntries, err := models.GetTimeEntriesByProjectID(c.Request.Context(), models.FromULID(projectID)) + timeEntries, err := models.GetTimeEntriesByProjectID(c.Request.Context(), types.FromULID(projectID)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) return @@ -390,7 +391,7 @@ func (h *TimeEntryHandler) DeleteTimeEntry(c *gin.Context) { } // Delete time entry from the database - err = models.DeleteTimeEntry(c.Request.Context(), models.FromULID(id)) + err = models.DeleteTimeEntry(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting time entry: "+err.Error()) return @@ -418,17 +419,17 @@ func convertTimeEntryToDTO(timeEntry *models.TimeEntry) dto.TimeEntryDto { func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEntryCreate, error) { // Convert IDs from int to ULID (this is a simplification, adjust as needed) - userID, err := models.ULIDWrapperFromString(dto.UserID) + userID, err := types.ULIDFromString(dto.UserID) if err != nil { return models.TimeEntryCreate{}, fmt.Errorf("invalid user ID: %w", err) } - projectID, err := models.ULIDWrapperFromString(dto.ProjectID) + projectID, err := types.ULIDFromString(dto.ProjectID) if err != nil { return models.TimeEntryCreate{}, fmt.Errorf("invalid project ID: %w", err) } - activityID, err := models.ULIDWrapperFromString(dto.ActivityID) + activityID, err := types.ULIDFromString(dto.ActivityID) if err != nil { return models.TimeEntryCreate{}, fmt.Errorf("invalid activity ID: %w", err) } @@ -450,11 +451,11 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn return models.TimeEntryUpdate{}, fmt.Errorf("invalid time entry ID: %w", err) } update := models.TimeEntryUpdate{ - ID: models.FromULID(id), + ID: types.FromULID(id), } if dto.UserID != nil { - userID, err := models.ULIDWrapperFromString(*dto.UserID) + userID, err := types.ULIDFromString(*dto.UserID) if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err) } @@ -462,7 +463,7 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn } if dto.ProjectID != nil { - projectID, err := models.ULIDWrapperFromString(*dto.ProjectID) + projectID, err := types.ULIDFromString(*dto.ProjectID) if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err) } @@ -470,7 +471,7 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn } if dto.ActivityID != nil { - activityID, err := models.ULIDWrapperFromString(*dto.ActivityID) + activityID, err := types.ULIDFromString(*dto.ActivityID) if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err) } diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index 09e8c94..6a5625a 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -9,6 +9,7 @@ import ( "github.com/timetracker/backend/internal/api/utils" dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" + "github.com/timetracker/backend/internal/types" ) // UserHandler handles user-related API endpoints @@ -73,7 +74,7 @@ func (h *UserHandler) GetUserByID(c *gin.Context) { } // Get user from the database - user, err := models.GetUserByID(c.Request.Context(), models.FromULID(id)) + user, err := models.GetUserByID(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error()) return @@ -163,11 +164,8 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { // Set ID from URL userUpdateDTO.ID = id.String() - // Set ID from URL - userUpdateDTO.ID = id.String() - // Convert DTO to Model - idWrapper := models.FromULID(id) + idWrapper := types.FromULID(id) update := models.UserUpdate{ ID: idWrapper, } @@ -183,7 +181,7 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { } if userUpdateDTO.CompanyID != nil { if userUpdateDTO.CompanyID.Valid { - companyID, err := models.ULIDWrapperFromString(*userUpdateDTO.CompanyID.Value) + companyID, err := types.ULIDFromString(*userUpdateDTO.CompanyID.Value) if err != nil { utils.BadRequestResponse(c, "Invalid company ID format") return @@ -191,7 +189,6 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { update.CompanyID = &companyID } else { update.CompanyID = nil - } } if userUpdateDTO.HourlyRate != nil { @@ -240,7 +237,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { } // Delete user from the database - err = models.DeleteUser(c.Request.Context(), models.FromULID(id)) + err = models.DeleteUser(c.Request.Context(), types.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting user: "+err.Error()) return @@ -360,7 +357,7 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) { } // Get user from the database - user, err := models.GetUserByID(c.Request.Context(), models.FromULID(userID)) + user, err := models.GetUserByID(c.Request.Context(), userID) if err != nil { utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error()) return @@ -397,9 +394,10 @@ func convertUserToDTO(user *models.User) dto.UserDto { } func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate { - var companyID models.ULIDWrapper - if dto.CompanyID != nil && dto.CompanyID.Valid { - companyID, _ = models.ULIDWrapperFromString(*dto.CompanyID.Value) // Ignoring error, validation happens in the model + var companyID *types.ULID + if dto.CompanyID != nil { + wrapper, _ := types.ULIDFromString(*dto.CompanyID) // Ignoring error, validation happens in the model + companyID = &wrapper } return models.UserCreate{ diff --git a/backend/internal/api/middleware/jwt_auth.go b/backend/internal/api/middleware/jwt_auth.go index 9a0b589..003b6be 100644 --- a/backend/internal/api/middleware/jwt_auth.go +++ b/backend/internal/api/middleware/jwt_auth.go @@ -17,6 +17,7 @@ import ( "github.com/timetracker/backend/internal/api/utils" "github.com/timetracker/backend/internal/config" "github.com/timetracker/backend/internal/models" + "github.com/timetracker/backend/internal/types" ) var ( @@ -318,41 +319,41 @@ func validateToken(tokenString string) (*Claims, error) { } // GetUserIDFromContext extracts the user ID from the context -func GetUserIDFromContext(c *gin.Context) (ulid.ULID, error) { +func GetUserIDFromContext(c *gin.Context) (types.ULID, error) { userID, exists := c.Get("userID") if !exists { - return ulid.ULID{}, errors.New("user ID not found in context") + return types.ULID{}, errors.New("user ID not found in context") } userIDStr, ok := userID.(string) if !ok { - return ulid.ULID{}, errors.New("invalid user ID type in context") + return types.ULID{}, errors.New("invalid user ID type in context") } id, err := ulid.Parse(userIDStr) if err != nil { - return ulid.ULID{}, err + return types.ULID{}, err } - return id, nil + return types.FromULID(id), nil } // GetCompanyIDFromContext extracts the company ID from the context -func GetCompanyIDFromContext(c *gin.Context) (ulid.ULID, error) { +func GetCompanyIDFromContext(c *gin.Context) (types.ULID, error) { companyID, exists := c.Get("companyID") if !exists { - return ulid.ULID{}, errors.New("company ID not found in context") + return types.ULID{}, errors.New("company ID not found in context") } companyIDStr, ok := companyID.(string) if !ok { - return ulid.ULID{}, errors.New("invalid company ID type in context") + return types.ULID{}, errors.New("invalid company ID type in context") } id, err := ulid.Parse(companyIDStr) if err != nil { - return ulid.ULID{}, err + return types.ULID{}, err } - return id, nil + return types.FromULID(id), nil } diff --git a/backend/internal/dtos/customer_dto.go b/backend/internal/dtos/customer_dto.go index b7601ae..b0bb45a 100644 --- a/backend/internal/dtos/customer_dto.go +++ b/backend/internal/dtos/customer_dto.go @@ -2,6 +2,8 @@ package dto import ( "time" + + "github.com/timetracker/backend/internal/types" ) type CustomerDto struct { @@ -10,19 +12,21 @@ type CustomerDto struct { UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"` LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"` Name string `json:"name" example:"John Doe"` - CompanyID string `json:"companyId" example:"01HGW2BBG0000000000000000"` + CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"` + OwnerUserID *string `json:"owningUserID" example:"01HGW2BBG0000000000000000"` } type CustomerCreateDto struct { - Name string `json:"name" example:"John Doe"` - CompanyID string `json:"companyId" example:"01HGW2BBG0000000000000000"` + Name string `json:"name" example:"John Doe"` + CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"` } type CustomerUpdateDto struct { - ID string `json:"id" example:"01HGW2BBG0000000000000000"` - CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"` - UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"` - LastEditorID *string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"` - Name *string `json:"name" example:"John Doe"` - CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"` + ID string `json:"id" example:"01HGW2BBG0000000000000000"` + CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"` + UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"` + LastEditorID *string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"` + Name *string `json:"name" example:"John Doe"` + CompanyID *types.Nullable[string] `json:"companyId" example:"01HGW2BBG0000000000000000"` + OwnerUserID *types.Nullable[string] `json:"owningUserID" example:"01HGW2BBG0000000000000000"` } diff --git a/backend/internal/dtos/helper/nullstring.go b/backend/internal/dtos/helper/nullstring.go deleted file mode 100644 index fc33090..0000000 --- a/backend/internal/dtos/helper/nullstring.go +++ /dev/null @@ -1,33 +0,0 @@ -package helper - -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 9373a4a..d5dbae8 100644 --- a/backend/internal/dtos/user_dto.go +++ b/backend/internal/dtos/user_dto.go @@ -18,11 +18,11 @@ type UserDto struct { } type UserCreateDto struct { - Email string `json:"email" example:"test@example.com"` - Password string `json:"password" example:"password123"` - Role string `json:"role" example:"admin"` - CompanyID *types.Nullable[string] `json:"companyId" example:"01HGW2BBG0000000000000000"` - HourlyRate float64 `json:"hourlyRate" example:"50.00"` + 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"` + HourlyRate float64 `json:"hourlyRate" example:"50.00"` } type UserUpdateDto struct { diff --git a/backend/internal/models/activity.go b/backend/internal/models/activity.go index 03ea070..2a7b1be 100644 --- a/backend/internal/models/activity.go +++ b/backend/internal/models/activity.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/timetracker/backend/internal/types" "gorm.io/gorm" ) @@ -21,9 +22,9 @@ func (Activity) TableName() string { // ActivityUpdate contains the updatable fields of an Activity type ActivityUpdate struct { - ID ULIDWrapper `gorm:"-"` // Use "-" to indicate that this field should be ignored - Name *string `gorm:"column:name"` - BillingRate *float64 `gorm:"column:billing_rate"` + ID types.ULID `gorm:"-"` // Use "-" to indicate that this field should be ignored + Name *string `gorm:"column:name"` + BillingRate *float64 `gorm:"column:billing_rate"` } // ActivityCreate contains the fields for creating a new Activity @@ -33,7 +34,7 @@ type ActivityCreate struct { } // GetActivityByID finds an Activity by its ID -func GetActivityByID(ctx context.Context, id ULIDWrapper) (*Activity, error) { +func GetActivityByID(ctx context.Context, id types.ULID) (*Activity, error) { var activity Activity result := GetEngine(ctx).Where("id = ?", id).First(&activity) if result.Error != nil { @@ -89,7 +90,7 @@ func UpdateActivity(ctx context.Context, update ActivityUpdate) (*Activity, erro } // DeleteActivity deletes an Activity by its ID -func DeleteActivity(ctx context.Context, id ULIDWrapper) error { +func DeleteActivity(ctx context.Context, id types.ULID) error { result := GetEngine(ctx).Delete(&Activity{}, id) return result.Error } diff --git a/backend/internal/models/base.go b/backend/internal/models/base.go index 80a9d44..2017cb8 100644 --- a/backend/internal/models/base.go +++ b/backend/internal/models/base.go @@ -6,11 +6,12 @@ import ( "time" "github.com/oklog/ulid/v2" + "github.com/timetracker/backend/internal/types" "gorm.io/gorm" ) type EntityBase struct { - ID ULIDWrapper `gorm:"type:bytea;primaryKey"` + ID types.ULID `gorm:"type:bytea;primaryKey"` CreatedAt time.Time `gorm:"index"` UpdatedAt time.Time `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"` @@ -18,11 +19,11 @@ type EntityBase struct { // BeforeCreate is called by GORM before creating a record func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error { - if eb.ID.Compare(ULIDWrapper{}) == 0 { // If ID is empty - // Generate a new ULID + if eb.ID.Compare(types.ULID{}) == 0 { // If ID is empty + // Generate a new types.ULID entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) newID := ulid.MustNew(ulid.Timestamp(time.Now()), entropy) - eb.ID = ULIDWrapper{ULID: newID} + eb.ID = types.ULID{ULID: newID} fmt.Println("Generated ID:", eb.ID) } return nil diff --git a/backend/internal/models/company.go b/backend/internal/models/company.go index cfe05f9..3a22264 100644 --- a/backend/internal/models/company.go +++ b/backend/internal/models/company.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/timetracker/backend/internal/types" "gorm.io/gorm" ) @@ -25,12 +26,12 @@ type CompanyCreate struct { // CompanyUpdate contains the updatable fields of a company type CompanyUpdate struct { - ID ULIDWrapper `gorm:"-"` // Exclude from updates - Name *string `gorm:"column:name"` + ID types.ULID `gorm:"-"` // Exclude from updates + Name *string `gorm:"column:name"` } // GetCompanyByID finds a company by its ID -func GetCompanyByID(ctx context.Context, id ULIDWrapper) (*Company, error) { +func GetCompanyByID(ctx context.Context, id types.ULID) (*Company, error) { var company Company result := GetEngine(ctx).Where("id = ?", id).First(&company) if result.Error != nil { @@ -94,7 +95,7 @@ func UpdateCompany(ctx context.Context, update CompanyUpdate) (*Company, error) } // DeleteCompany deletes a company by its ID -func DeleteCompany(ctx context.Context, id ULIDWrapper) error { +func DeleteCompany(ctx context.Context, id types.ULID) error { result := GetEngine(ctx).Delete(&Company{}, id) return result.Error } diff --git a/backend/internal/models/customer.go b/backend/internal/models/customer.go index f39041b..1ec224b 100644 --- a/backend/internal/models/customer.go +++ b/backend/internal/models/customer.go @@ -4,14 +4,16 @@ import ( "context" "errors" + "github.com/timetracker/backend/internal/types" "gorm.io/gorm" ) // Customer represents a customer in the system type Customer struct { EntityBase - Name string `gorm:"column:name"` - CompanyID ULIDWrapper `gorm:"type:bytea;column:company_id"` + Name string `gorm:"column:name"` + CompanyID *types.ULID `gorm:"type:bytea;column:company_id"` + OwnerUserID *types.ULID `gorm:"type:bytea;column:owner_user_id"` } // TableName specifies the table name for GORM @@ -21,19 +23,21 @@ func (Customer) TableName() string { // CustomerCreate contains the fields for creating a new customer type CustomerCreate struct { - Name string - CompanyID ULIDWrapper + Name string + CompanyID *types.ULID + OwnerUserID *types.ULID } // CustomerUpdate contains the updatable fields of a customer type CustomerUpdate struct { - ID ULIDWrapper `gorm:"-"` // Exclude from updates - Name *string `gorm:"column:name"` - CompanyID *ULIDWrapper `gorm:"column:company_id"` + ID types.ULID `gorm:"-"` // Exclude from updates + Name *string `gorm:"column:name"` + CompanyID *types.ULID `gorm:"column:company_id"` + OwnerUserID *types.ULID `gorm:"column:owner_user_id"` } // GetCustomerByID finds a customer by its ID -func GetCustomerByID(ctx context.Context, id ULIDWrapper) (*Customer, error) { +func GetCustomerByID(ctx context.Context, id types.ULID) (*Customer, error) { var customer Customer result := GetEngine(ctx).Where("id = ?", id).First(&customer) if result.Error != nil { @@ -89,7 +93,7 @@ func UpdateCustomer(ctx context.Context, update CustomerUpdate) (*Customer, erro } // DeleteCustomer deletes a customer by its ID -func DeleteCustomer(ctx context.Context, id ULIDWrapper) error { +func DeleteCustomer(ctx context.Context, id types.ULID) error { result := GetEngine(ctx).Delete(&Customer{}, id) return result.Error } diff --git a/backend/internal/models/project.go b/backend/internal/models/project.go index fd78181..1f6e65a 100644 --- a/backend/internal/models/project.go +++ b/backend/internal/models/project.go @@ -6,14 +6,15 @@ import ( "fmt" "github.com/oklog/ulid/v2" + "github.com/timetracker/backend/internal/types" "gorm.io/gorm" ) // Project represents a project in the system type Project struct { EntityBase - Name string `gorm:"column:name;not null"` - CustomerID ULIDWrapper `gorm:"column:customer_id;type:bytea;not null"` + Name string `gorm:"column:name;not null"` + CustomerID types.ULID `gorm:"column:customer_id;type:bytea;not null"` // Relationships (for Eager Loading) Customer *Customer `gorm:"foreignKey:CustomerID"` @@ -27,14 +28,14 @@ func (Project) TableName() string { // ProjectCreate contains the fields for creating a new project type ProjectCreate struct { Name string - CustomerID ULIDWrapper + CustomerID types.ULID } // ProjectUpdate contains the updatable fields of a project type ProjectUpdate struct { - ID ULIDWrapper `gorm:"-"` // Exclude from updates - Name *string `gorm:"column:name"` - CustomerID *ULIDWrapper `gorm:"column:customer_id"` + ID types.ULID `gorm:"-"` // Exclude from updates + Name *string `gorm:"column:name"` + CustomerID *types.ULID `gorm:"column:customer_id"` } // Validate checks if the Create struct contains valid data @@ -43,7 +44,7 @@ func (pc *ProjectCreate) Validate() error { return errors.New("project name cannot be empty") } // Check for valid CustomerID - if pc.CustomerID.Compare(ULIDWrapper{}) == 0 { + if pc.CustomerID.Compare(types.ULID{}) == 0 { return errors.New("customerID cannot be empty") } return nil @@ -58,7 +59,7 @@ func (pu *ProjectUpdate) Validate() error { } // GetProjectByID finds a project by its ID -func GetProjectByID(ctx context.Context, id ULIDWrapper) (*Project, error) { +func GetProjectByID(ctx context.Context, id types.ULID) (*Project, error) { var project Project result := GetEngine(ctx).Where("id = ?", id).First(&project) if result.Error != nil { diff --git a/backend/internal/models/timeentry.go b/backend/internal/models/timeentry.go index e8a4b1b..6b32016 100644 --- a/backend/internal/models/timeentry.go +++ b/backend/internal/models/timeentry.go @@ -6,19 +6,20 @@ import ( "fmt" "time" + "github.com/timetracker/backend/internal/types" "gorm.io/gorm" ) // TimeEntry represents a time entry in the system type TimeEntry struct { EntityBase - UserID ULIDWrapper `gorm:"column:user_id;type:bytea;not null;index"` - ProjectID ULIDWrapper `gorm:"column:project_id;type:bytea;not null;index"` - ActivityID ULIDWrapper `gorm:"column:activity_id;type:bytea;not null;index"` - Start time.Time `gorm:"column:start;not null"` - End time.Time `gorm:"column:end;not null"` - Description string `gorm:"column:description"` - Billable int `gorm:"column:billable"` // Percentage (0-100) + UserID types.ULID `gorm:"column:user_id;type:bytea;not null;index"` + ProjectID types.ULID `gorm:"column:project_id;type:bytea;not null;index"` + ActivityID types.ULID `gorm:"column:activity_id;type:bytea;not null;index"` + Start time.Time `gorm:"column:start;not null"` + End time.Time `gorm:"column:end;not null"` + Description string `gorm:"column:description"` + Billable int `gorm:"column:billable"` // Percentage (0-100) // Relationships for Eager Loading User *User `gorm:"foreignKey:UserID"` @@ -33,9 +34,9 @@ func (TimeEntry) TableName() string { // TimeEntryCreate contains the fields for creating a new time entry type TimeEntryCreate struct { - UserID ULIDWrapper - ProjectID ULIDWrapper - ActivityID ULIDWrapper + UserID types.ULID + ProjectID types.ULID + ActivityID types.ULID Start time.Time End time.Time Description string @@ -44,26 +45,26 @@ type TimeEntryCreate struct { // TimeEntryUpdate contains the updatable fields of a time entry type TimeEntryUpdate struct { - ID ULIDWrapper `gorm:"-"` // Exclude from updates - UserID *ULIDWrapper `gorm:"column:user_id"` - ProjectID *ULIDWrapper `gorm:"column:project_id"` - ActivityID *ULIDWrapper `gorm:"column:activity_id"` - Start *time.Time `gorm:"column:start"` - End *time.Time `gorm:"column:end"` - Description *string `gorm:"column:description"` - Billable *int `gorm:"column:billable"` + ID types.ULID `gorm:"-"` // Exclude from updates + UserID *types.ULID `gorm:"column:user_id"` + ProjectID *types.ULID `gorm:"column:project_id"` + ActivityID *types.ULID `gorm:"column:activity_id"` + Start *time.Time `gorm:"column:start"` + End *time.Time `gorm:"column:end"` + Description *string `gorm:"column:description"` + Billable *int `gorm:"column:billable"` } // Validate checks if the Create struct contains valid data func (tc *TimeEntryCreate) Validate() error { // Check for empty IDs - if tc.UserID.Compare(ULIDWrapper{}) == 0 { + if tc.UserID.Compare(types.ULID{}) == 0 { return errors.New("userID cannot be empty") } - if tc.ProjectID.Compare(ULIDWrapper{}) == 0 { + if tc.ProjectID.Compare(types.ULID{}) == 0 { return errors.New("projectID cannot be empty") } - if tc.ActivityID.Compare(ULIDWrapper{}) == 0 { + if tc.ActivityID.Compare(types.ULID{}) == 0 { return errors.New("activityID cannot be empty") } @@ -102,7 +103,7 @@ func (tu *TimeEntryUpdate) Validate() error { } // GetTimeEntryByID finds a time entry by its ID -func GetTimeEntryByID(ctx context.Context, id ULIDWrapper) (*TimeEntry, error) { +func GetTimeEntryByID(ctx context.Context, id types.ULID) (*TimeEntry, error) { var timeEntry TimeEntry result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry) if result.Error != nil { @@ -115,7 +116,7 @@ func GetTimeEntryByID(ctx context.Context, id ULIDWrapper) (*TimeEntry, error) { } // GetTimeEntryWithRelations loads a time entry with all associated data -func GetTimeEntryWithRelations(ctx context.Context, id ULIDWrapper) (*TimeEntry, error) { +func GetTimeEntryWithRelations(ctx context.Context, id types.ULID) (*TimeEntry, error) { var timeEntry TimeEntry result := GetEngine(ctx). Preload("User"). @@ -145,7 +146,7 @@ func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) { } // GetTimeEntriesByUserID returns all time entries of a user -func GetTimeEntriesByUserID(ctx context.Context, userID ULIDWrapper) ([]TimeEntry, error) { +func GetTimeEntriesByUserID(ctx context.Context, userID types.ULID) ([]TimeEntry, error) { var timeEntries []TimeEntry result := GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries) if result.Error != nil { @@ -155,7 +156,7 @@ func GetTimeEntriesByUserID(ctx context.Context, userID ULIDWrapper) ([]TimeEntr } // GetTimeEntriesByProjectID returns all time entries of a project -func GetTimeEntriesByProjectID(ctx context.Context, projectID ULIDWrapper) ([]TimeEntry, error) { +func GetTimeEntriesByProjectID(ctx context.Context, projectID types.ULID) ([]TimeEntry, error) { var timeEntries []TimeEntry result := GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries) if result.Error != nil { @@ -180,7 +181,7 @@ func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]Tim } // SumBillableHoursByProject calculates the billable hours per project -func SumBillableHoursByProject(ctx context.Context, projectID ULIDWrapper) (float64, error) { +func SumBillableHoursByProject(ctx context.Context, projectID types.ULID) (float64, error) { type Result struct { TotalHours float64 } @@ -246,7 +247,7 @@ func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, e } // validateReferences checks if all referenced entities exist -func validateReferences(tx *gorm.DB, userID, projectID, activityID ULIDWrapper) error { +func validateReferences(tx *gorm.DB, userID, projectID, activityID types.ULID) error { // Check user var userCount int64 if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil { @@ -350,7 +351,7 @@ func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, e } // DeleteTimeEntry deletes a time entry by its ID -func DeleteTimeEntry(ctx context.Context, id ULIDWrapper) error { +func DeleteTimeEntry(ctx context.Context, id types.ULID) error { result := GetEngine(ctx).Delete(&TimeEntry{}, id) if result.Error != nil { return fmt.Errorf("error deleting the time entry: %w", result.Error) diff --git a/backend/internal/models/ulid_extension.go b/backend/internal/models/ulid_extension.go deleted file mode 100644 index 12e6cf9..0000000 --- a/backend/internal/models/ulid_extension.go +++ /dev/null @@ -1,77 +0,0 @@ -package models - -import ( - "context" - "database/sql/driver" - "fmt" - - "github.com/oklog/ulid/v2" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// ULIDWrapper wraps ulid.ULID to make it work nicely with GORM -type ULIDWrapper struct { - ulid.ULID -} - -// NewULIDWrapper creates a new ULIDWrapper with a new ULID -func NewULIDWrapper() ULIDWrapper { - return ULIDWrapper{ULID: ulid.Make()} -} - -// FromULID creates a ULIDWrapper from a ulid.ULID -func FromULID(id ulid.ULID) ULIDWrapper { - return ULIDWrapper{ULID: id} -} - -// ULIDWrapperFromString creates a ULIDWrapper from a string -func ULIDWrapperFromString(id string) (ULIDWrapper, error) { - parsed, err := ulid.Parse(id) - if err != nil { - return ULIDWrapper{}, fmt.Errorf("failed to parse ULID string: %w", err) - } - return ULIDWrapper{ULID: parsed}, nil -} - -// Scan implements the sql.Scanner interface for ULIDWrapper -func (u *ULIDWrapper) Scan(src any) error { - switch v := src.(type) { - case []byte: - // If it's exactly 16 bytes, it's the binary representation - if len(v) == 16 { - copy(u.ULID[:], v) - return nil - } - // Otherwise, try as string - return fmt.Errorf("cannot scan []byte of length %d into ULIDWrapper", len(v)) - case string: - parsed, err := ulid.Parse(v) - if err != nil { - return fmt.Errorf("failed to parse ULID: %w", err) - } - u.ULID = parsed - return nil - default: - return fmt.Errorf("cannot scan %T into ULIDWrapper", src) - } -} - -// Value implements the driver.Valuer interface for ULIDWrapper -// Returns the binary representation of the ULID for maximum efficiency -func (u ULIDWrapper) Value() (driver.Value, error) { - return u.ULID.Bytes(), nil -} - -// GormValue implements the gorm.Valuer interface for ULIDWrapper -func (u ULIDWrapper) GormValue(ctx context.Context, db *gorm.DB) clause.Expr { - return clause.Expr{ - SQL: "?", - Vars: []any{u.Bytes()}, - } -} - -// Compare implements comparison for ULIDWrapper -func (u ULIDWrapper) Compare(other ULIDWrapper) int { - return u.ULID.Compare(other.ULID) -} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 4ba878d..e34f8f2 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -11,6 +11,7 @@ import ( "slices" + "github.com/timetracker/backend/internal/types" "golang.org/x/crypto/argon2" "gorm.io/gorm" ) @@ -35,12 +36,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;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 *types.ULID `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"` @@ -56,18 +57,18 @@ type UserCreate struct { Email string Password string Role string - CompanyID ULIDWrapper + CompanyID *types.ULID HourlyRate float64 } // UserUpdate contains the updatable fields of a user type UserUpdate struct { - ID ULIDWrapper `gorm:"-"` // Exclude from updates - Email *string `gorm:"column:email"` - Password *string `gorm:"-"` // Not stored directly in DB - Role *string `gorm:"column:role"` - CompanyID *ULIDWrapper `gorm:"column:company_id"` - HourlyRate *float64 `gorm:"column:hourly_rate"` + ID types.ULID `gorm:"-"` // Exclude from updates + Email *string `gorm:"column:email"` + Password *string `gorm:"-"` // Not stored directly in DB + Role *string `gorm:"column:role"` + CompanyID *types.ULID `gorm:"column:company_id"` + HourlyRate *float64 `gorm:"column:hourly_rate"` } // PasswordData contains the data for password hash and salt @@ -201,7 +202,7 @@ func (uc *UserCreate) Validate() error { } } - if uc.CompanyID.Compare(ULIDWrapper{}) == 0 { + if uc.CompanyID.Compare(types.ULID{}) == 0 { return errors.New("companyID cannot be empty") } @@ -287,7 +288,7 @@ func (uu *UserUpdate) Validate() error { } // GetUserByID finds a user by their ID -func GetUserByID(ctx context.Context, id ULIDWrapper) (*User, error) { +func GetUserByID(ctx context.Context, id types.ULID) (*User, error) { var user User result := GetEngine(ctx).Where("id = ?", id).First(&user) if result.Error != nil { @@ -313,7 +314,7 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) { } // GetUserWithCompany loads a user with their company -func GetUserWithCompany(ctx context.Context, id ULIDWrapper) (*User, error) { +func GetUserWithCompany(ctx context.Context, id types.ULID) (*User, error) { var user User result := GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user) if result.Error != nil { @@ -336,7 +337,7 @@ func GetAllUsers(ctx context.Context) ([]User, error) { } // getCompanyCondition builds the company condition for queries -func getCompanyCondition(companyID *ULIDWrapper) func(db *gorm.DB) *gorm.DB { +func getCompanyCondition(companyID *types.ULID) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { if companyID == nil { return db.Where("company_id IS NULL") @@ -346,7 +347,7 @@ func getCompanyCondition(companyID *ULIDWrapper) func(db *gorm.DB) *gorm.DB { } // GetUsersByCompanyID returns all users of a company -func GetUsersByCompanyID(ctx context.Context, companyID ULIDWrapper) ([]User, error) { +func GetUsersByCompanyID(ctx context.Context, companyID types.ULID) ([]User, error) { var users []User // Apply the dynamic company condition condition := getCompanyCondition(&companyID) @@ -398,7 +399,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, } @@ -509,7 +510,7 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) { } // DeleteUser deletes a user by their ID -func DeleteUser(ctx context.Context, id ULIDWrapper) error { +func DeleteUser(ctx context.Context, id types.ULID) error { // Here one could check if dependent entities exist // e.g., don't delete if time entries still exist diff --git a/backend/internal/types/wrapped-uild.go b/backend/internal/types/wrapped-uild.go new file mode 100644 index 0000000..34809f1 --- /dev/null +++ b/backend/internal/types/wrapped-uild.go @@ -0,0 +1,77 @@ +package types + +import ( + "context" + "database/sql/driver" + "fmt" + + "github.com/oklog/ulid/v2" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ULID wraps ulid.ULID to make it work nicely with GORM +type ULID struct { + ulid.ULID +} + +// NewULIDWrapper creates a new ULID with a new ULID +func NewULIDWrapper() ULID { + return ULID{ULID: ulid.Make()} +} + +// FromULID creates a ULID from a ulid.ULID +func FromULID(id ulid.ULID) ULID { + return ULID{ULID: id} +} + +// ULIDWrapperFromString creates a ULID from a string +func ULIDFromString(id string) (ULID, error) { + parsed, err := ulid.Parse(id) + if err != nil { + return ULID{}, fmt.Errorf("failed to parse ULID string: %w", err) + } + return ULID{ULID: parsed}, nil +} + +// Scan implements the sql.Scanner interface for ULID +func (u *ULID) Scan(src any) error { + switch v := src.(type) { + case []byte: + // If it's exactly 16 bytes, it's the binary representation + if len(v) == 16 { + copy(u.ULID[:], v) + return nil + } + // Otherwise, try as string + return fmt.Errorf("cannot scan []byte of length %d into ULID", len(v)) + case string: + parsed, err := ulid.Parse(v) + if err != nil { + return fmt.Errorf("failed to parse ULID: %w", err) + } + u.ULID = parsed + return nil + default: + return fmt.Errorf("cannot scan %T into ULID", src) + } +} + +// Value implements the driver.Valuer interface for ULID +// Returns the binary representation of the ULID for maximum efficiency +func (u ULID) Value() (driver.Value, error) { + return u.ULID.Bytes(), nil +} + +// GormValue implements the gorm.Valuer interface for ULID +func (u ULID) GormValue(ctx context.Context, db *gorm.DB) clause.Expr { + return clause.Expr{ + SQL: "?", + Vars: []any{u.Bytes()}, + } +} + +// Compare implements comparison for ULID +func (u ULID) Compare(other ULID) int { + return u.ULID.Compare(other.ULID) +} diff --git a/backend/tygo.yaml b/backend/tygo.yaml index d0c1770..8d5f1c5 100644 --- a/backend/tygo.yaml +++ b/backend/tygo.yaml @@ -2,6 +2,6 @@ packages: - path: github.com/timetracker/backend/internal/dtos type_mappings: "time.Time": "string" - "ulid.ULID": "string" + "types.ULID": "string" "types.Nullable": "Nullable" output_path: ../frontend/src/types/dto.ts diff --git a/frontend/src/types/dto.ts b/frontend/src/types/dto.ts index fa7fdae..fb64228 100644 --- a/frontend/src/types/dto.ts +++ b/frontend/src/types/dto.ts @@ -73,11 +73,12 @@ export interface CustomerDto { updatedAt: string; lastEditorID: string; name: string; - companyId: string; + companyId?: string; + owningUserID?: string; } export interface CustomerCreateDto { name: string; - companyId: string; + companyId?: string; } export interface CustomerUpdateDto { id: string; @@ -85,7 +86,8 @@ export interface CustomerUpdateDto { updatedAt?: string; lastEditorID?: string; name?: string; - companyId?: string; + companyId?: Nullable; + owningUserID?: Nullable; } ////////// @@ -168,7 +170,7 @@ export interface UserCreateDto { email: string; password: string; role: string; - companyId?: Nullable; + companyId?: string; hourlyRate: number /* float64 */; } export interface UserUpdateDto {