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

This commit is contained in:
2025-03-12 09:32:29 +00:00
parent 233f3cdb5c
commit 4170eb5fbd
21 changed files with 269 additions and 264 deletions
+6 -5
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
}
+5 -4
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
+5 -4
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
}
+13 -9
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
}
+9 -8
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 {
+29 -28
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)
-77
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)
}
+21 -20
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