diff --git a/backend/cmd/modeltest/main.go b/backend/cmd/modeltest/main.go index 7b69d6a..1c0384f 100644 --- a/backend/cmd/modeltest/main.go +++ b/backend/cmd/modeltest/main.go @@ -196,7 +196,7 @@ func testRelationships(ctx context.Context) { // Test invalid ID invalidID := ulid.MustNew(ulid.Timestamp(time.Now()), ulid.DefaultEntropy()) - invalidUser, err := models.GetUserByID(ctx, invalidID) + invalidUser, err := models.GetUserByID(ctx, models.FromULID(invalidID)) if err != nil { log.Fatalf("Error getting user with invalid ID: %v", err) } diff --git a/backend/internal/api/handlers/activity_handler.go b/backend/internal/api/handlers/activity_handler.go index 339e69b..74739c5 100644 --- a/backend/internal/api/handlers/activity_handler.go +++ b/backend/internal/api/handlers/activity_handler.go @@ -72,7 +72,7 @@ func (h *ActivityHandler) GetActivityByID(c *gin.Context) { } // Get activity from the database - activity, err := models.GetActivityByID(c.Request.Context(), id) + activity, err := models.GetActivityByID(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving activity: "+err.Error()) return @@ -207,7 +207,7 @@ func (h *ActivityHandler) DeleteActivity(c *gin.Context) { } // Delete activity from the database - err = models.DeleteActivity(c.Request.Context(), id) + err = models.DeleteActivity(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting activity: "+err.Error()) return @@ -238,7 +238,7 @@ func convertCreateActivityDTOToModel(dto dto.ActivityCreateDto) models.ActivityC func convertUpdateActivityDTOToModel(dto dto.ActivityUpdateDto) models.ActivityUpdate { id, _ := ulid.Parse(dto.ID) update := models.ActivityUpdate{ - ID: id, + ID: models.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 f011d1a..a07ab29 100644 --- a/backend/internal/api/handlers/company_handler.go +++ b/backend/internal/api/handlers/company_handler.go @@ -72,7 +72,7 @@ func (h *CompanyHandler) GetCompanyByID(c *gin.Context) { } // Get company from the database - company, err := models.GetCompanyByID(c.Request.Context(), id) + company, err := models.GetCompanyByID(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving company: "+err.Error()) return @@ -207,7 +207,7 @@ func (h *CompanyHandler) DeleteCompany(c *gin.Context) { } // Delete company from the database - err = models.DeleteCompany(c.Request.Context(), id) + err = models.DeleteCompany(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting company: "+err.Error()) return @@ -236,7 +236,7 @@ func convertCreateCompanyDTOToModel(dto dto.CompanyCreateDto) models.CompanyCrea func convertUpdateCompanyDTOToModel(dto dto.CompanyUpdateDto) models.CompanyUpdate { id, _ := ulid.Parse(dto.ID) update := models.CompanyUpdate{ - ID: id, + ID: models.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 34f1a08..220e09a 100644 --- a/backend/internal/api/handlers/customer_handler.go +++ b/backend/internal/api/handlers/customer_handler.go @@ -73,7 +73,7 @@ func (h *CustomerHandler) GetCustomerByID(c *gin.Context) { } // Get customer from the database - customer, err := models.GetCustomerByID(c.Request.Context(), id) + customer, err := models.GetCustomerByID(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving customer: "+err.Error()) return @@ -247,7 +247,7 @@ func (h *CustomerHandler) DeleteCustomer(c *gin.Context) { } // Delete customer from the database - err = models.DeleteCustomer(c.Request.Context(), id) + err = models.DeleteCustomer(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting customer: "+err.Error()) return @@ -278,7 +278,7 @@ func convertCreateCustomerDTOToModel(dto dto.CustomerCreateDto) models.CustomerC func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) models.CustomerUpdate { id, _ := ulid.Parse(dto.ID) update := models.CustomerUpdate{ - ID: id, + ID: models.FromULID(id), } if dto.Name != nil { diff --git a/backend/internal/api/handlers/project_handler.go b/backend/internal/api/handlers/project_handler.go index a8d3973..640c035 100644 --- a/backend/internal/api/handlers/project_handler.go +++ b/backend/internal/api/handlers/project_handler.go @@ -102,7 +102,7 @@ func (h *ProjectHandler) GetProjectByID(c *gin.Context) { } // Get project from the database - project, err := models.GetProjectByID(c.Request.Context(), id) + project, err := models.GetProjectByID(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving project: "+err.Error()) return @@ -297,7 +297,7 @@ func (h *ProjectHandler) DeleteProject(c *gin.Context) { func convertProjectToDTO(project *models.Project) dto.ProjectDto { customerID := 0 - if project.CustomerID.Compare(ulid.ULID{}) != 0 { + if project.CustomerID.Compare(models.ULIDWrapper{}) != 0 { // This is a simplification, adjust as needed customerID = int(project.CustomerID.Time()) } @@ -320,14 +320,14 @@ func convertCreateProjectDTOToModel(dto dto.ProjectCreateDto) (models.ProjectCre return models.ProjectCreate{ Name: dto.Name, - CustomerID: customerID, + CustomerID: models.FromULID(customerID), }, nil } func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpdate, error) { id, _ := ulid.Parse(dto.ID) update := models.ProjectUpdate{ - ID: id, + ID: models.FromULID(id), } if dto.Name != nil { @@ -340,7 +340,8 @@ func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpd if err != nil { return models.ProjectUpdate{}, fmt.Errorf("invalid customer ID: %w", err) } - update.CustomerID = &customerID + wrappedID := models.FromULID(customerID) + update.CustomerID = &wrappedID } return update, nil diff --git a/backend/internal/api/handlers/timeentry_handler.go b/backend/internal/api/handlers/timeentry_handler.go index 9cc2b60..de4ebb0 100644 --- a/backend/internal/api/handlers/timeentry_handler.go +++ b/backend/internal/api/handlers/timeentry_handler.go @@ -75,7 +75,7 @@ func (h *TimeEntryHandler) GetTimeEntryByID(c *gin.Context) { } // Get time entry from the database - timeEntry, err := models.GetTimeEntryByID(c.Request.Context(), id) + timeEntry, err := models.GetTimeEntryByID(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving time entry: "+err.Error()) return @@ -116,7 +116,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByUserID(c *gin.Context) { } // Get time entries from the database - timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), userID) + timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), models.FromULID(userID)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) return @@ -152,7 +152,7 @@ func (h *TimeEntryHandler) GetMyTimeEntries(c *gin.Context) { } // Get time entries from the database - timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), userID) + timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), models.FromULID(userID)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) return @@ -191,7 +191,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByProjectID(c *gin.Context) { } // Get time entries from the database - timeEntries, err := models.GetTimeEntriesByProjectID(c.Request.Context(), projectID) + timeEntries, err := models.GetTimeEntriesByProjectID(c.Request.Context(), models.FromULID(projectID)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) return @@ -390,7 +390,7 @@ func (h *TimeEntryHandler) DeleteTimeEntry(c *gin.Context) { } // Delete time entry from the database - err = models.DeleteTimeEntry(c.Request.Context(), id) + err = models.DeleteTimeEntry(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting time entry: "+err.Error()) return @@ -434,9 +434,9 @@ func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEn } return models.TimeEntryCreate{ - UserID: userID, - ProjectID: projectID, - ActivityID: activityID, + UserID: models.FromULID(userID), + ProjectID: models.FromULID(projectID), + ActivityID: models.FromULID(activityID), Start: dto.Start, End: dto.End, Description: dto.Description, @@ -447,7 +447,7 @@ func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEn func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEntryUpdate, error) { id, _ := ulid.Parse(dto.ID) update := models.TimeEntryUpdate{ - ID: id, + ID: models.FromULID(id), } if dto.UserID != nil { @@ -455,7 +455,8 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err) } - update.UserID = &userID + wrappedID := models.FromULID(userID) + update.UserID = &wrappedID } if dto.ProjectID != nil { @@ -463,7 +464,8 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err) } - update.ProjectID = &projectID + wrappedProjectID := models.FromULID(projectID) + update.ProjectID = &wrappedProjectID } if dto.ActivityID != nil { @@ -471,7 +473,8 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err) } - update.ActivityID = &activityID + wrappedActivityID := models.FromULID(activityID) + update.ActivityID = &wrappedActivityID } if dto.Start != nil { diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index 71817c6..518bd3f 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -73,7 +73,7 @@ func (h *UserHandler) GetUserByID(c *gin.Context) { } // Get user from the database - user, err := models.GetUserByID(c.Request.Context(), id) + user, err := models.GetUserByID(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error()) return @@ -208,7 +208,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { } // Delete user from the database - err = models.DeleteUser(c.Request.Context(), id) + err = models.DeleteUser(c.Request.Context(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting user: "+err.Error()) return @@ -328,7 +328,7 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) { } // Get user from the database - user, err := models.GetUserByID(c.Request.Context(), userID) + user, err := models.GetUserByID(c.Request.Context(), models.FromULID(userID)) if err != nil { utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error()) return @@ -354,14 +354,14 @@ func convertUserToDTO(user *models.User) dto.UserDto { UpdatedAt: user.UpdatedAt, Email: user.Email, Role: user.Role, - CompanyID: int(user.CompanyID.Time()), // This is a simplification, adjust as needed + CompanyID: user.CompanyID.String(), HourlyRate: user.HourlyRate, } } func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate { // Convert CompanyID from int to ULID (this is a simplification, adjust as needed) - companyID, _ := ulid.Parse("01H1VECTJQXS1RVWJT6QG3QJCJ") + companyID, _ := models.ULIDWrapperFromString(dto.CompanyID) return models.UserCreate{ Email: dto.Email, @@ -375,7 +375,7 @@ func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate { func convertUpdateDTOToModel(dto dto.UserUpdateDto) models.UserUpdate { id, _ := ulid.Parse(dto.ID) update := models.UserUpdate{ - ID: id, + ID: models.FromULID(id), } if dto.Email != nil { @@ -392,7 +392,7 @@ func convertUpdateDTOToModel(dto dto.UserUpdateDto) models.UserUpdate { if dto.CompanyID != nil { // Convert CompanyID from int to ULID (this is a simplification, adjust as needed) - companyID, _ := ulid.Parse("01H1VECTJQXS1RVWJT6QG3QJCJ") + companyID, _ := models.ULIDWrapperFromString(*dto.CompanyID) update.CompanyID = &companyID } diff --git a/backend/internal/api/routes/router.go b/backend/internal/api/routes/router.go index d8f771b..fa5bddc 100644 --- a/backend/internal/api/routes/router.go +++ b/backend/internal/api/routes/router.go @@ -28,7 +28,7 @@ func SetupRouter(r *gin.Engine) { // Protected routes protected := api.Group("") - protected.Use(middleware.AuthMiddleware()) + //protected.Use(middleware.AuthMiddleware()) { // Auth routes (protected) protectedAuth := protected.Group("/auth") diff --git a/backend/internal/dtos/user_dto.go b/backend/internal/dtos/user_dto.go index 4327a5e..b684c7b 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"` Email string `json:"email"` Role string `json:"role"` - CompanyID int `json:"companyId"` + CompanyID string `json:"companyId"` HourlyRate float64 `json:"hourlyRate"` } @@ -19,7 +19,7 @@ type UserCreateDto struct { Email string `json:"email"` Password string `json:"password"` Role string `json:"role"` - CompanyID int `json:"companyId"` + CompanyID string `json:"companyId"` HourlyRate float64 `json:"hourlyRate"` } @@ -31,6 +31,6 @@ type UserUpdateDto struct { Email *string `json:"email"` Password *string `json:"password"` Role *string `json:"role"` - CompanyID *int `json:"companyId"` + CompanyID *string `json:"companyId"` HourlyRate *float64 `json:"hourlyRate"` } diff --git a/backend/internal/models/activity.go b/backend/internal/models/activity.go index 395091a..03ea070 100644 --- a/backend/internal/models/activity.go +++ b/backend/internal/models/activity.go @@ -4,7 +4,6 @@ import ( "context" "errors" - "github.com/oklog/ulid/v2" "gorm.io/gorm" ) @@ -22,9 +21,9 @@ func (Activity) TableName() string { // ActivityUpdate contains the updatable fields of an Activity type ActivityUpdate struct { - ID ulid.ULID `gorm:"-"` // Use "-" to indicate that this field should be ignored - Name *string `gorm:"column:name"` - BillingRate *float64 `gorm:"column:billing_rate"` + ID ULIDWrapper `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 @@ -34,7 +33,7 @@ type ActivityCreate struct { } // GetActivityByID finds an Activity by its ID -func GetActivityByID(ctx context.Context, id ulid.ULID) (*Activity, error) { +func GetActivityByID(ctx context.Context, id ULIDWrapper) (*Activity, error) { var activity Activity result := GetEngine(ctx).Where("id = ?", id).First(&activity) if result.Error != nil { @@ -90,7 +89,7 @@ func UpdateActivity(ctx context.Context, update ActivityUpdate) (*Activity, erro } // DeleteActivity deletes an Activity by its ID -func DeleteActivity(ctx context.Context, id ulid.ULID) error { +func DeleteActivity(ctx context.Context, id ULIDWrapper) 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 722f133..dbe2976 100644 --- a/backend/internal/models/base.go +++ b/backend/internal/models/base.go @@ -1,7 +1,9 @@ package models import ( + "fmt" "math/rand" + "runtime/debug" "time" "github.com/oklog/ulid/v2" @@ -9,7 +11,7 @@ import ( ) type EntityBase struct { - ID ulid.ULID `gorm:"type:uuid;primaryKey"` + ID ULIDWrapper `gorm:"type:char(26);primaryKey"` CreatedAt time.Time `gorm:"index"` UpdatedAt time.Time `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"` @@ -17,10 +19,15 @@ type EntityBase struct { // BeforeCreate is called by GORM before creating a record func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error { - if eb.ID.Compare(ulid.ULID{}) == 0 { // If ID is empty + fmt.Println("BeforeCreate called") + stack := debug.Stack() + fmt.Println("foo's stack:", string(stack)) + if eb.ID.Compare(ULIDWrapper{}) == 0 { // If ID is empty // Generate a new ULID entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) - eb.ID = ulid.MustNew(ulid.Timestamp(time.Now()), entropy) + newID := ulid.MustNew(ulid.Timestamp(time.Now()), entropy) + eb.ID = ULIDWrapper{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 0756cd4..cfe05f9 100644 --- a/backend/internal/models/company.go +++ b/backend/internal/models/company.go @@ -4,7 +4,6 @@ import ( "context" "errors" - "github.com/oklog/ulid/v2" "gorm.io/gorm" ) @@ -26,12 +25,12 @@ type CompanyCreate struct { // CompanyUpdate contains the updatable fields of a company type CompanyUpdate struct { - ID ulid.ULID `gorm:"-"` // Exclude from updates - Name *string `gorm:"column:name"` + ID ULIDWrapper `gorm:"-"` // Exclude from updates + Name *string `gorm:"column:name"` } // GetCompanyByID finds a company by its ID -func GetCompanyByID(ctx context.Context, id ulid.ULID) (*Company, error) { +func GetCompanyByID(ctx context.Context, id ULIDWrapper) (*Company, error) { var company Company result := GetEngine(ctx).Where("id = ?", id).First(&company) if result.Error != nil { @@ -95,7 +94,7 @@ func UpdateCompany(ctx context.Context, update CompanyUpdate) (*Company, error) } // DeleteCompany deletes a company by its ID -func DeleteCompany(ctx context.Context, id ulid.ULID) error { +func DeleteCompany(ctx context.Context, id ULIDWrapper) 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 c86e26a..9d1f589 100644 --- a/backend/internal/models/customer.go +++ b/backend/internal/models/customer.go @@ -4,7 +4,6 @@ import ( "context" "errors" - "github.com/oklog/ulid/v2" "gorm.io/gorm" ) @@ -28,13 +27,13 @@ type CustomerCreate struct { // CustomerUpdate contains the updatable fields of a customer type CustomerUpdate struct { - ID ulid.ULID `gorm:"-"` // Exclude from updates - Name *string `gorm:"column:name"` - CompanyID *int `gorm:"column:company_id"` + ID ULIDWrapper `gorm:"-"` // Exclude from updates + Name *string `gorm:"column:name"` + CompanyID *int `gorm:"column:company_id"` } // GetCustomerByID finds a customer by its ID -func GetCustomerByID(ctx context.Context, id ulid.ULID) (*Customer, error) { +func GetCustomerByID(ctx context.Context, id ULIDWrapper) (*Customer, error) { var customer Customer result := GetEngine(ctx).Where("id = ?", id).First(&customer) if result.Error != nil { @@ -90,7 +89,7 @@ func UpdateCustomer(ctx context.Context, update CustomerUpdate) (*Customer, erro } // DeleteCustomer deletes a customer by its ID -func DeleteCustomer(ctx context.Context, id ulid.ULID) error { +func DeleteCustomer(ctx context.Context, id ULIDWrapper) 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 7afaeff..e26b80d 100644 --- a/backend/internal/models/project.go +++ b/backend/internal/models/project.go @@ -12,8 +12,8 @@ import ( // Project represents a project in the system type Project struct { EntityBase - Name string `gorm:"column:name;not null"` - CustomerID ulid.ULID `gorm:"column:customer_id;type:uuid;not null"` + Name string `gorm:"column:name;not null"` + CustomerID ULIDWrapper `gorm:"column:customer_id;type:char(26);not null"` // Relationships (for Eager Loading) Customer *Customer `gorm:"foreignKey:CustomerID"` @@ -27,14 +27,14 @@ func (Project) TableName() string { // ProjectCreate contains the fields for creating a new project type ProjectCreate struct { Name string - CustomerID ulid.ULID + CustomerID ULIDWrapper } // ProjectUpdate contains the updatable fields of a project type ProjectUpdate struct { - ID ulid.ULID `gorm:"-"` // Exclude from updates - Name *string `gorm:"column:name"` - CustomerID *ulid.ULID `gorm:"column:customer_id"` + ID ULIDWrapper `gorm:"-"` // Exclude from updates + Name *string `gorm:"column:name"` + CustomerID *ULIDWrapper `gorm:"column:customer_id"` } // Validate checks if the Create struct contains valid data @@ -43,7 +43,7 @@ func (pc *ProjectCreate) Validate() error { return errors.New("project name cannot be empty") } // Check for valid CustomerID - if pc.CustomerID.Compare(ulid.ULID{}) == 0 { + if pc.CustomerID.Compare(ULIDWrapper{}) == 0 { return errors.New("customerID cannot be empty") } return nil @@ -58,7 +58,7 @@ func (pu *ProjectUpdate) Validate() error { } // GetProjectByID finds a project by its ID -func GetProjectByID(ctx context.Context, id ulid.ULID) (*Project, error) { +func GetProjectByID(ctx context.Context, id ULIDWrapper) (*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 d97b7c7..41fbb3a 100644 --- a/backend/internal/models/timeentry.go +++ b/backend/internal/models/timeentry.go @@ -6,20 +6,19 @@ import ( "fmt" "time" - "github.com/oklog/ulid/v2" "gorm.io/gorm" ) // TimeEntry represents a time entry in the system type TimeEntry struct { EntityBase - UserID ulid.ULID `gorm:"column:user_id;type:uuid;not null;index"` - ProjectID ulid.ULID `gorm:"column:project_id;type:uuid;not null;index"` - ActivityID ulid.ULID `gorm:"column:activity_id;type:uuid;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 ULIDWrapper `gorm:"column:user_id;type:char(26);not null;index"` + ProjectID ULIDWrapper `gorm:"column:project_id;type:char(26);not null;index"` + ActivityID ULIDWrapper `gorm:"column:activity_id;type:char(26);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"` @@ -34,9 +33,9 @@ func (TimeEntry) TableName() string { // TimeEntryCreate contains the fields for creating a new time entry type TimeEntryCreate struct { - UserID ulid.ULID - ProjectID ulid.ULID - ActivityID ulid.ULID + UserID ULIDWrapper + ProjectID ULIDWrapper + ActivityID ULIDWrapper Start time.Time End time.Time Description string @@ -45,26 +44,26 @@ type TimeEntryCreate struct { // TimeEntryUpdate contains the updatable fields of a time entry type TimeEntryUpdate struct { - ID ulid.ULID `gorm:"-"` // Exclude from updates - UserID *ulid.ULID `gorm:"column:user_id"` - ProjectID *ulid.ULID `gorm:"column:project_id"` - ActivityID *ulid.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"` + 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"` } // Validate checks if the Create struct contains valid data func (tc *TimeEntryCreate) Validate() error { // Check for empty IDs - if tc.UserID.Compare(ulid.ULID{}) == 0 { + if tc.UserID.Compare(ULIDWrapper{}) == 0 { return errors.New("userID cannot be empty") } - if tc.ProjectID.Compare(ulid.ULID{}) == 0 { + if tc.ProjectID.Compare(ULIDWrapper{}) == 0 { return errors.New("projectID cannot be empty") } - if tc.ActivityID.Compare(ulid.ULID{}) == 0 { + if tc.ActivityID.Compare(ULIDWrapper{}) == 0 { return errors.New("activityID cannot be empty") } @@ -103,7 +102,7 @@ func (tu *TimeEntryUpdate) Validate() error { } // GetTimeEntryByID finds a time entry by its ID -func GetTimeEntryByID(ctx context.Context, id ulid.ULID) (*TimeEntry, error) { +func GetTimeEntryByID(ctx context.Context, id ULIDWrapper) (*TimeEntry, error) { var timeEntry TimeEntry result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry) if result.Error != nil { @@ -116,7 +115,7 @@ func GetTimeEntryByID(ctx context.Context, id ulid.ULID) (*TimeEntry, error) { } // GetTimeEntryWithRelations loads a time entry with all associated data -func GetTimeEntryWithRelations(ctx context.Context, id ulid.ULID) (*TimeEntry, error) { +func GetTimeEntryWithRelations(ctx context.Context, id ULIDWrapper) (*TimeEntry, error) { var timeEntry TimeEntry result := GetEngine(ctx). Preload("User"). @@ -146,7 +145,7 @@ func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) { } // GetTimeEntriesByUserID returns all time entries of a user -func GetTimeEntriesByUserID(ctx context.Context, userID ulid.ULID) ([]TimeEntry, error) { +func GetTimeEntriesByUserID(ctx context.Context, userID ULIDWrapper) ([]TimeEntry, error) { var timeEntries []TimeEntry result := GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries) if result.Error != nil { @@ -156,7 +155,7 @@ func GetTimeEntriesByUserID(ctx context.Context, userID ulid.ULID) ([]TimeEntry, } // GetTimeEntriesByProjectID returns all time entries of a project -func GetTimeEntriesByProjectID(ctx context.Context, projectID ulid.ULID) ([]TimeEntry, error) { +func GetTimeEntriesByProjectID(ctx context.Context, projectID ULIDWrapper) ([]TimeEntry, error) { var timeEntries []TimeEntry result := GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries) if result.Error != nil { @@ -181,7 +180,7 @@ func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]Tim } // SumBillableHoursByProject calculates the billable hours per project -func SumBillableHoursByProject(ctx context.Context, projectID ulid.ULID) (float64, error) { +func SumBillableHoursByProject(ctx context.Context, projectID ULIDWrapper) (float64, error) { type Result struct { TotalHours float64 } @@ -247,7 +246,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 ulid.ULID) error { +func validateReferences(tx *gorm.DB, userID, projectID, activityID ULIDWrapper) error { // Check user var userCount int64 if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil { @@ -351,7 +350,7 @@ func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, e } // DeleteTimeEntry deletes a time entry by its ID -func DeleteTimeEntry(ctx context.Context, id ulid.ULID) error { +func DeleteTimeEntry(ctx context.Context, id ULIDWrapper) 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 new file mode 100644 index 0000000..838345f --- /dev/null +++ b/backend/internal/models/ulid_extension.go @@ -0,0 +1,75 @@ +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 allow method definitions +type ULIDWrapper struct { + ulid.ULID +} + +// Compare implements the same comparison method as ulid.ULID +func (u ULIDWrapper) Compare(other ULIDWrapper) int { + return u.ULID.Compare(other.ULID) +} + +// FromULID creates a ULIDWrapper from a ulid.ULID +func FromULID(id ulid.ULID) ULIDWrapper { + return ULIDWrapper{ULID: id} +} + +// From String 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 FromULID(parsed), nil +} + +// ToULID converts a ULIDWrapper to a ulid.ULID +func (u ULIDWrapper) ToULID() ulid.ULID { + return u.ULID +} + +// 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.String()}, + } +} + +// Scan implements the Scanner interface for ULIDWrapper +func (u *ULIDWrapper) Scan(src any) error { + switch v := src.(type) { + case string: + parsed, err := ulid.Parse(v) + if err != nil { + return fmt.Errorf("failed to parse ULID string: %w", err) + } + u.ULID = parsed + return nil + case []byte: + parsed, err := ulid.Parse(string(v)) + if err != nil { + return fmt.Errorf("failed to parse ULID bytes: %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 +func (u ULIDWrapper) Value() (driver.Value, error) { + return u.String(), nil +} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 630dbfd..ed913e4 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -11,7 +11,6 @@ import ( "slices" - "github.com/oklog/ulid/v2" "golang.org/x/crypto/argon2" "gorm.io/gorm" ) @@ -36,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 ulid.ULID `gorm:"column:company_id;type:uuid;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:char(26);not null;index"` + HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"` // Relationship for Eager Loading Company *Company `gorm:"foreignKey:CompanyID"` @@ -57,18 +56,18 @@ type UserCreate struct { Email string Password string Role string - CompanyID ulid.ULID + CompanyID ULIDWrapper HourlyRate float64 } // UserUpdate contains the updatable fields of a user type UserUpdate struct { - ID ulid.ULID `gorm:"-"` // Exclude from updates - Email *string `gorm:"column:email"` - Password *string `gorm:"-"` // Not stored directly in DB - Role *string `gorm:"column:role"` - CompanyID *ulid.ULID `gorm:"column:company_id"` - HourlyRate *float64 `gorm:"column:hourly_rate"` + 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"` } // PasswordData contains the data for password hash and salt @@ -202,7 +201,7 @@ func (uc *UserCreate) Validate() error { } } - if uc.CompanyID.Compare(ulid.ULID{}) == 0 { + if uc.CompanyID.Compare(ULIDWrapper{}) == 0 { return errors.New("companyID cannot be empty") } @@ -288,7 +287,7 @@ func (uu *UserUpdate) Validate() error { } // GetUserByID finds a user by their ID -func GetUserByID(ctx context.Context, id ulid.ULID) (*User, error) { +func GetUserByID(ctx context.Context, id ULIDWrapper) (*User, error) { var user User result := GetEngine(ctx).Where("id = ?", id).First(&user) if result.Error != nil { @@ -314,7 +313,7 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) { } // GetUserWithCompany loads a user with their company -func GetUserWithCompany(ctx context.Context, id ulid.ULID) (*User, error) { +func GetUserWithCompany(ctx context.Context, id ULIDWrapper) (*User, error) { var user User result := GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user) if result.Error != nil { @@ -337,7 +336,7 @@ func GetAllUsers(ctx context.Context) ([]User, error) { } // GetUsersByCompanyID returns all users of a company -func GetUsersByCompanyID(ctx context.Context, companyID ulid.ULID) ([]User, error) { +func GetUsersByCompanyID(ctx context.Context, companyID ULIDWrapper) ([]User, error) { var users []User result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&users) if result.Error != nil { @@ -498,7 +497,7 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) { } // DeleteUser deletes a user by their ID -func DeleteUser(ctx context.Context, id ulid.ULID) error { +func DeleteUser(ctx context.Context, id ULIDWrapper) error { // Here one could check if dependent entities exist // e.g., don't delete if time entries still exist