feat: Refactor DTOs to use types.ULID and update companyId fields to be optional

This commit is contained in:
Jean Jacques Avril 2025-03-12 09:32:29 +00:00
parent 233f3cdb5c
commit 4170eb5fbd
21 changed files with 269 additions and 264 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string>;
owningUserID?: Nullable<string>;
}
//////////
@ -168,7 +170,7 @@ export interface UserCreateDto {
email: string;
password: string;
role: string;
companyId?: Nullable<string>;
companyId?: string;
hourlyRate: number /* float64 */;
}
export interface UserUpdateDto {