From 9057adebdd4bd2b4449604a7f64301465b243595 Mon Sep 17 00:00:00 2001 From: Jean Jacques Avril Date: Tue, 11 Mar 2025 23:11:49 +0000 Subject: [PATCH] feat: Update database models and DTOs to use bytea for ULIDWrapper and add JWT configuration to environment --- .env | 7 +- backend/Makefile | 32 ++- backend/cmd/dbtest/main.go | 18 +- backend/cmd/migrate/main.go | 40 +++- .../internal/api/handlers/customer_handler.go | 45 +++- .../internal/api/handlers/project_handler.go | 33 +-- .../api/handlers/timeentry_handler.go | 48 ++--- .../api/middleware/{auth.go => jwt_auth.go} | 82 +++++++- backend/internal/dtos/customer_dto.go | 6 +- backend/internal/dtos/project_dto.go | 6 +- backend/internal/dtos/timeentry_dto.go | 18 +- backend/internal/models/base.go | 6 +- backend/internal/models/customer.go | 12 +- backend/internal/models/db.go | 25 +++ backend/internal/models/project.go | 2 +- backend/internal/models/timeentry.go | 6 +- backend/internal/models/ulid_extension.go | 60 +++--- backend/internal/models/user.go | 2 +- docu/database_schema.md | 194 ------------------ 19 files changed, 315 insertions(+), 327 deletions(-) rename backend/internal/api/middleware/{auth.go => jwt_auth.go} (69%) delete mode 100644 docu/database_schema.md diff --git a/.env b/.env index 94b0bdc..1247237 100644 --- a/.env +++ b/.env @@ -4,4 +4,9 @@ DB_USER=timetracker DB_PASSWORD=password DB_NAME=timetracker DB_SSLMODE=disable -API_KEY= \ No newline at end of file +API_KEY= + +# JWT Configuration +JWT_SECRET=test +JWT_KEY_DIR=keys +JWT_KEY_GENERATE=true \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile index dc34852..39b3e5b 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,4 +1,3 @@ - # Time Tracker Backend Makefile .PHONY: db-start db-stop db-test model-test run build clean migrate seed help @@ -24,6 +23,8 @@ help: @echo " make clean - Remove build artifacts" @echo " make migrate - Run database migrations" @echo " make seed - Seed the database with initial data" + @echo " make db-drop-users - Drop the users table" + @echo " make db-reinit - Re-initialize the database" @echo " make help - Show this help message" # Start the database @@ -76,3 +77,32 @@ seed: @echo "Seeding the database..." @go run -mod=mod cmd/seed/main.go @echo "Seeding complete" + +# Drop the users table +db-drop-users: + @echo "Dropping the users table..." + @export PG_HOST=$(DB_HOST); export PG_PORT=$(DB_PORT); export PG_USER=$(DB_USER); export PG_PASSWORD=$(DB_PASSWORD); export PG_DBNAME=$(DB_NAME); go run cmd/dbtest/main.go -drop_table=users + @echo "Users table dropped" + +# Re-initialize the database +db-reinit: + @echo "Re-initializing the database..." + @PG_HOST=$(DB_HOST) PG_PORT=$(DB_PORT) PG_USER=$(DB_USER) PG_PASSWORD=$(DB_PASSWORD) PG_DBNAME=$(DB_NAME) go run cmd/migrate/main.go -create_db -drop_db + @echo "Database re-initialized" + +help: + @echo "Time Tracker Backend Makefile" + @echo "" + @echo "Usage:" + @echo " make db-start - Start the PostgreSQL database container" + @echo " make db-stop - Stop the PostgreSQL database container" + @echo " make db-test - Test the database connection" + @echo " make model-test - Test the database models" + @echo " make run - Run the application" + @echo " make build - Build the application" + @echo " make clean - Remove build artifacts" + @echo " make migrate - Run database migrations" + @echo " make seed - Seed the database with initial data" + @echo " make db-drop-users - Drop the users table" + @echo " make db-reinit - Re-initialize the database" + @echo " make help - Show this help message" diff --git a/backend/cmd/dbtest/main.go b/backend/cmd/dbtest/main.go index 191adf2..bbdc974 100644 --- a/backend/cmd/dbtest/main.go +++ b/backend/cmd/dbtest/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "flag" "fmt" "log" "time" @@ -10,6 +11,9 @@ import ( ) func main() { + dropTable := flag.String("drop_table", "", "Drop the specified table") + flag.Parse() + // Get database configuration with sensible defaults dbConfig := models.DefaultDatabaseConfig() @@ -34,7 +38,19 @@ func main() { // Test database connection with a simple query var result int - err := db.Raw("SELECT 1").Scan(&result).Error + var err error + + if *dropTable != "" { + fmt.Printf("Dropping table %s...\n", *dropTable) + dropErr := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", *dropTable)).Error + if dropErr != nil { + log.Fatalf("Error dropping table %s: %v", *dropTable, dropErr) + } + fmt.Printf("✓ Table %s dropped successfully\n", *dropTable) + return + } + + err = db.Raw("SELECT 1").Scan(&result).Error if err != nil { log.Fatalf("Error executing test query: %v", err) } diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go index 11c7069..727b7c4 100644 --- a/backend/cmd/migrate/main.go +++ b/backend/cmd/migrate/main.go @@ -1,6 +1,7 @@ package main import ( + "flag" "fmt" "log" "os" @@ -12,6 +13,11 @@ import ( func main() { // Parse command line flags verbose := false + dropDB := flag.Bool("drop_db", false, "Drop the database before migrating") + createDB := flag.Bool("create_db", false, "Create the database if it doesn't exist") + + flag.Parse() + for _, arg := range os.Args[1:] { if arg == "--verbose" || arg == "-v" { verbose = true @@ -53,7 +59,37 @@ func main() { // Initialize database fmt.Println("Connecting to database...") - if err := models.InitDB(dbConfig); err != nil { + + var err error + + gormDB, err := models.GetGormDB(dbConfig, "postgres") + if err != nil { + log.Fatalf("Error getting gorm DB: %v", err) + } + sqlDB, err := gormDB.DB() + if err != nil { + log.Fatalf("Error getting sql DB: %v", err) + } + + if *dropDB { + fmt.Printf("Dropping database %s...\n", dbConfig.DBName) + _, err = sqlDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbConfig.DBName)) + if err != nil { + log.Fatalf("Error dropping database %s: %v", dbConfig.DBName, err) + } + fmt.Printf("✓ Database %s dropped successfully\n", dbConfig.DBName) + } + + if *createDB { + fmt.Printf("Creating database %s...\n", dbConfig.DBName) + _, err = sqlDB.Exec(fmt.Sprintf("CREATE DATABASE %s", dbConfig.DBName)) + if err != nil { + log.Fatalf("Error creating database %s: %v", dbConfig.DBName, err) + } + fmt.Printf("✓ Database %s created successfully\n", dbConfig.DBName) + } + + if err = models.InitDB(dbConfig); err != nil { log.Fatalf("Error initializing database: %v", err) } defer func() { @@ -65,7 +101,7 @@ func main() { // Run migrations fmt.Println("Running database migrations...") - if err := models.MigrateDB(); err != nil { + if err = models.MigrateDB(); err != nil { log.Fatalf("Error migrating database: %v", err) } fmt.Println("✓ Database migrations completed successfully") diff --git a/backend/internal/api/handlers/customer_handler.go b/backend/internal/api/handlers/customer_handler.go index 220e09a..4b47d2e 100644 --- a/backend/internal/api/handlers/customer_handler.go +++ b/backend/internal/api/handlers/customer_handler.go @@ -152,7 +152,11 @@ func (h *CustomerHandler) CreateCustomer(c *gin.Context) { } // Convert DTO to model - customerCreate := convertCreateCustomerDTOToModel(customerCreateDTO) + customerCreate, err := convertCreateCustomerDTOToModel(customerCreateDTO) + if err != nil { + utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) + return + } // Create customer in the database customer, err := models.CreateCustomer(c.Request.Context(), customerCreate) @@ -203,7 +207,11 @@ func (h *CustomerHandler) UpdateCustomer(c *gin.Context) { customerUpdateDTO.ID = id.String() // Convert DTO to model - customerUpdate := convertUpdateCustomerDTOToModel(customerUpdateDTO) + customerUpdate, err := convertUpdateCustomerDTOToModel(customerUpdateDTO) + if err != nil { + utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) + return + } // Update customer in the database customer, err := models.UpdateCustomer(c.Request.Context(), customerUpdate) @@ -264,21 +272,32 @@ func convertCustomerToDTO(customer *models.Customer) dto.CustomerDto { CreatedAt: customer.CreatedAt, UpdatedAt: customer.UpdatedAt, Name: customer.Name, - CompanyID: customer.CompanyID, + CompanyID: customer.CompanyID.String(), } } -func convertCreateCustomerDTOToModel(dto dto.CustomerCreateDto) models.CustomerCreate { - return models.CustomerCreate{ +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) + } + + create := models.CustomerCreate{ Name: dto.Name, - CompanyID: dto.CompanyID, + CompanyID: companyID, } + return create, nil } -func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) models.CustomerUpdate { - id, _ := ulid.Parse(dto.ID) +func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) (models.CustomerUpdate, error) { + id, err := models.ULIDWrapperFromString(dto.ID) + if err != nil { + return models.CustomerUpdate{}, fmt.Errorf("invalid customer ID: %w", err) + } + update := models.CustomerUpdate{ - ID: models.FromULID(id), + ID: id, } if dto.Name != nil { @@ -286,10 +305,14 @@ func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) models.CustomerU } if dto.CompanyID != nil { - update.CompanyID = dto.CompanyID + companyID, err := models.ULIDWrapperFromString(*dto.CompanyID) + if err != nil { + return models.CustomerUpdate{}, fmt.Errorf("invalid company ID: %w", err) + } + update.CompanyID = &companyID } - return update + return update, nil } // Helper function to parse company ID from string diff --git a/backend/internal/api/handlers/project_handler.go b/backend/internal/api/handlers/project_handler.go index 640c035..d8c52b5 100644 --- a/backend/internal/api/handlers/project_handler.go +++ b/backend/internal/api/handlers/project_handler.go @@ -296,36 +296,34 @@ func (h *ProjectHandler) DeleteProject(c *gin.Context) { // Helper functions for DTO conversion func convertProjectToDTO(project *models.Project) dto.ProjectDto { - customerID := 0 - if project.CustomerID.Compare(models.ULIDWrapper{}) != 0 { - // This is a simplification, adjust as needed - customerID = int(project.CustomerID.Time()) - } return dto.ProjectDto{ ID: project.ID.String(), CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, Name: project.Name, - CustomerID: customerID, + CustomerID: project.CustomerID.String(), } } func convertCreateProjectDTOToModel(dto dto.ProjectCreateDto) (models.ProjectCreate, error) { // Convert CustomerID from int to ULID (this is a simplification, adjust as needed) - customerID, err := customerIDToULID(dto.CustomerID) + customerID, err := models.ULIDWrapperFromString(dto.CustomerID) if err != nil { return models.ProjectCreate{}, fmt.Errorf("invalid customer ID: %w", err) } return models.ProjectCreate{ Name: dto.Name, - CustomerID: models.FromULID(customerID), + CustomerID: customerID, }, nil } func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpdate, error) { - id, _ := ulid.Parse(dto.ID) + id, err := ulid.Parse(dto.ID) + if err != nil { + return models.ProjectUpdate{}, fmt.Errorf("invalid project ID: %w", err) + } update := models.ProjectUpdate{ ID: models.FromULID(id), } @@ -336,25 +334,12 @@ 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 := customerIDToULID(*dto.CustomerID) + customerID, err := models.ULIDWrapperFromString(*dto.CustomerID) if err != nil { return models.ProjectUpdate{}, fmt.Errorf("invalid customer ID: %w", err) } - wrappedID := models.FromULID(customerID) - update.CustomerID = &wrappedID + update.CustomerID = &customerID } return update, nil } - -// Helper function to convert customer ID from int to ULID -func customerIDToULID(id int) (ulid.ULID, error) { - // This is a simplification, in a real application you would need to - // fetch the actual ULID from the database or use a proper conversion method - // For now, we'll create a deterministic ULID based on the int value - entropy := ulid.Monotonic(nil, 0) - timestamp := uint64(id) - - // Create a new ULID with the timestamp and entropy - return ulid.MustNew(timestamp, entropy), nil -} diff --git a/backend/internal/api/handlers/timeentry_handler.go b/backend/internal/api/handlers/timeentry_handler.go index de4ebb0..c7556f0 100644 --- a/backend/internal/api/handlers/timeentry_handler.go +++ b/backend/internal/api/handlers/timeentry_handler.go @@ -406,9 +406,9 @@ func convertTimeEntryToDTO(timeEntry *models.TimeEntry) dto.TimeEntryDto { ID: timeEntry.ID.String(), CreatedAt: timeEntry.CreatedAt, UpdatedAt: timeEntry.UpdatedAt, - UserID: int(timeEntry.UserID.Time()), // Simplified conversion - ProjectID: int(timeEntry.ProjectID.Time()), // Simplified conversion - ActivityID: int(timeEntry.ActivityID.Time()), // Simplified conversion + UserID: timeEntry.UserID.String(), // Simplified conversion + ProjectID: timeEntry.ProjectID.String(), // Simplified conversion + ActivityID: timeEntry.ActivityID.String(), // Simplified conversion Start: timeEntry.Start, End: timeEntry.End, Description: timeEntry.Description, @@ -418,25 +418,25 @@ 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 := idToULID(dto.UserID) + userID, err := models.ULIDWrapperFromString(dto.UserID) if err != nil { return models.TimeEntryCreate{}, fmt.Errorf("invalid user ID: %w", err) } - projectID, err := idToULID(dto.ProjectID) + projectID, err := models.ULIDWrapperFromString(dto.ProjectID) if err != nil { return models.TimeEntryCreate{}, fmt.Errorf("invalid project ID: %w", err) } - activityID, err := idToULID(dto.ActivityID) + activityID, err := models.ULIDWrapperFromString(dto.ActivityID) if err != nil { return models.TimeEntryCreate{}, fmt.Errorf("invalid activity ID: %w", err) } return models.TimeEntryCreate{ - UserID: models.FromULID(userID), - ProjectID: models.FromULID(projectID), - ActivityID: models.FromULID(activityID), + UserID: userID, + ProjectID: projectID, + ActivityID: activityID, Start: dto.Start, End: dto.End, Description: dto.Description, @@ -445,36 +445,36 @@ func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEn } func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEntryUpdate, error) { - id, _ := ulid.Parse(dto.ID) + id, err := ulid.Parse(dto.ID) + if err != nil { + return models.TimeEntryUpdate{}, fmt.Errorf("invalid time entry ID: %w", err) + } update := models.TimeEntryUpdate{ ID: models.FromULID(id), } if dto.UserID != nil { - userID, err := idToULID(*dto.UserID) + userID, err := models.ULIDWrapperFromString(*dto.UserID) if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err) } - wrappedID := models.FromULID(userID) - update.UserID = &wrappedID + update.UserID = &userID } if dto.ProjectID != nil { - projectID, err := idToULID(*dto.ProjectID) + projectID, err := models.ULIDWrapperFromString(*dto.ProjectID) if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err) } - wrappedProjectID := models.FromULID(projectID) - update.ProjectID = &wrappedProjectID + update.ProjectID = &projectID } if dto.ActivityID != nil { - activityID, err := idToULID(*dto.ActivityID) + activityID, err := models.ULIDWrapperFromString(*dto.ActivityID) if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err) } - wrappedActivityID := models.FromULID(activityID) - update.ActivityID = &wrappedActivityID + update.ActivityID = &activityID } if dto.Start != nil { @@ -495,13 +495,3 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn return update, nil } - -// Helper function to convert ID from int to ULID -func idToULID(id int) (ulid.ULID, error) { - // This is a simplification, in a real application you would need to - // fetch the actual ULID from the database or use a proper conversion method - // For now, we'll create a deterministic ULID based on the int value - entropy := ulid.Monotonic(nil, 0) - timestamp := uint64(id) - return ulid.MustNew(timestamp, entropy), nil -} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/jwt_auth.go similarity index 69% rename from backend/internal/api/middleware/auth.go rename to backend/internal/api/middleware/jwt_auth.go index a7389b6..a00becc 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/jwt_auth.go @@ -1,23 +1,97 @@ package middleware import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "errors" + "fmt" + "os" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "github.com/joho/godotenv" "github.com/oklog/ulid/v2" "github.com/timetracker/backend/internal/api/utils" "github.com/timetracker/backend/internal/models" ) -// JWT configuration -const ( - // This should be moved to environment variables in production - jwtSecret = "your-secret-key-change-in-production" +var ( + jwtSecret string tokenDuration = 24 * time.Hour ) +func init() { + // Load .env file + _ = godotenv.Load() + + // Get JWT secret from environment + jwtSecret = os.Getenv("JWT_SECRET") + + // Generate a random secret if none is provided + if jwtSecret == "" { + randomBytes := make([]byte, 32) + _, err := rand.Read(randomBytes) + if err != nil { + panic("failed to generate JWT secret: " + err.Error()) + } + jwtSecret = string(randomBytes) + } + + // Generate and store RSA keys if configured + if os.Getenv("JWT_KEY_GENERATE") == "true" { + keyDir := os.Getenv("JWT_KEY_DIR") + if keyDir == "" { + keyDir = "./keys" + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(keyDir, 0755); err != nil { + panic("failed to create key directory: " + err.Error()) + } + + // Generate RSA key pair + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic("failed to generate RSA key pair: " + err.Error()) + } + + // Save private key + privateKeyFile, err := os.Create(fmt.Sprintf("%s/private.pem", keyDir)) + if err != nil { + panic("failed to create private key file: " + err.Error()) + } + defer privateKeyFile.Close() + + privateKeyPEM := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + } + + if err := pem.Encode(privateKeyFile, privateKeyPEM); err != nil { + panic("failed to encode private key: " + err.Error()) + } + + // Save public key + publicKeyFile, err := os.Create(fmt.Sprintf("%s/public.pem", keyDir)) + if err != nil { + panic("failed to create public key file: " + err.Error()) + } + defer publicKeyFile.Close() + + publicKeyPEM := &pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: x509.MarshalPKCS1PublicKey(&privateKey.PublicKey), + } + + if err := pem.Encode(publicKeyFile, publicKeyPEM); err != nil { + panic("failed to encode public key: " + err.Error()) + } + } +} + // Claims represents the JWT claims type Claims struct { UserID string `json:"userId"` diff --git a/backend/internal/dtos/customer_dto.go b/backend/internal/dtos/customer_dto.go index 6fd5686..9caf88e 100644 --- a/backend/internal/dtos/customer_dto.go +++ b/backend/internal/dtos/customer_dto.go @@ -10,12 +10,12 @@ type CustomerDto struct { UpdatedAt time.Time `json:"updatedAt"` LastEditorID string `json:"lastEditorID"` Name string `json:"name"` - CompanyID int `json:"companyId"` + CompanyID string `json:"companyId"` } type CustomerCreateDto struct { Name string `json:"name"` - CompanyID int `json:"companyId"` + CompanyID string `json:"companyId"` } type CustomerUpdateDto struct { @@ -24,5 +24,5 @@ type CustomerUpdateDto struct { UpdatedAt *time.Time `json:"updatedAt"` LastEditorID *string `json:"lastEditorID"` Name *string `json:"name"` - CompanyID *int `json:"companyId"` + CompanyID *string `json:"companyId"` } diff --git a/backend/internal/dtos/project_dto.go b/backend/internal/dtos/project_dto.go index 820dc79..d078bff 100644 --- a/backend/internal/dtos/project_dto.go +++ b/backend/internal/dtos/project_dto.go @@ -10,12 +10,12 @@ type ProjectDto struct { UpdatedAt time.Time `json:"updatedAt"` LastEditorID string `json:"lastEditorID"` Name string `json:"name"` - CustomerID int `json:"customerId"` + CustomerID string `json:"customerId"` } type ProjectCreateDto struct { Name string `json:"name"` - CustomerID int `json:"customerId"` + CustomerID string `json:"customerId"` } type ProjectUpdateDto struct { @@ -24,5 +24,5 @@ type ProjectUpdateDto struct { UpdatedAt *time.Time `json:"updatedAt"` LastEditorID *string `json:"lastEditorID"` Name *string `json:"name"` - CustomerID *int `json:"customerId"` + CustomerID *string `json:"customerId"` } diff --git a/backend/internal/dtos/timeentry_dto.go b/backend/internal/dtos/timeentry_dto.go index 2497ce8..5b8cf09 100644 --- a/backend/internal/dtos/timeentry_dto.go +++ b/backend/internal/dtos/timeentry_dto.go @@ -9,9 +9,9 @@ type TimeEntryDto struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` LastEditorID string `json:"lastEditorID"` - UserID int `json:"userId"` - ProjectID int `json:"projectId"` - ActivityID int `json:"activityId"` + UserID string `json:"userId"` + ProjectID string `json:"projectId"` + ActivityID string `json:"activityId"` Start time.Time `json:"start"` End time.Time `json:"end"` Description string `json:"description"` @@ -19,9 +19,9 @@ type TimeEntryDto struct { } type TimeEntryCreateDto struct { - UserID int `json:"userId"` - ProjectID int `json:"projectId"` - ActivityID int `json:"activityId"` + UserID string `json:"userId"` + ProjectID string `json:"projectId"` + ActivityID string `json:"activityId"` Start time.Time `json:"start"` End time.Time `json:"end"` Description string `json:"description"` @@ -33,9 +33,9 @@ type TimeEntryUpdateDto struct { CreatedAt *time.Time `json:"createdAt"` UpdatedAt *time.Time `json:"updatedAt"` LastEditorID *string `json:"lastEditorID"` - UserID *int `json:"userId"` - ProjectID *int `json:"projectId"` - ActivityID *int `json:"activityId"` + UserID *string `json:"userId"` + ProjectID *string `json:"projectId"` + ActivityID *string `json:"activityId"` Start *time.Time `json:"start"` End *time.Time `json:"end"` Description *string `json:"description"` diff --git a/backend/internal/models/base.go b/backend/internal/models/base.go index dbe2976..80a9d44 100644 --- a/backend/internal/models/base.go +++ b/backend/internal/models/base.go @@ -3,7 +3,6 @@ package models import ( "fmt" "math/rand" - "runtime/debug" "time" "github.com/oklog/ulid/v2" @@ -11,7 +10,7 @@ import ( ) type EntityBase struct { - ID ULIDWrapper `gorm:"type:char(26);primaryKey"` + ID ULIDWrapper `gorm:"type:bytea;primaryKey"` CreatedAt time.Time `gorm:"index"` UpdatedAt time.Time `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"` @@ -19,9 +18,6 @@ type EntityBase struct { // BeforeCreate is called by GORM before creating a record func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error { - 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) diff --git a/backend/internal/models/customer.go b/backend/internal/models/customer.go index 9d1f589..f39041b 100644 --- a/backend/internal/models/customer.go +++ b/backend/internal/models/customer.go @@ -10,8 +10,8 @@ import ( // Customer represents a customer in the system type Customer struct { EntityBase - Name string `gorm:"column:name"` - CompanyID int `gorm:"column:company_id"` + Name string `gorm:"column:name"` + CompanyID ULIDWrapper `gorm:"type:bytea;column:company_id"` } // TableName specifies the table name for GORM @@ -22,14 +22,14 @@ func (Customer) TableName() string { // CustomerCreate contains the fields for creating a new customer type CustomerCreate struct { Name string - CompanyID int + CompanyID ULIDWrapper } // CustomerUpdate contains the updatable fields of a customer type CustomerUpdate struct { - ID ULIDWrapper `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 *ULIDWrapper `gorm:"column:company_id"` } // GetCustomerByID finds a customer by its ID diff --git a/backend/internal/models/db.go b/backend/internal/models/db.go index cd02074..0709e89 100644 --- a/backend/internal/models/db.go +++ b/backend/internal/models/db.go @@ -141,6 +141,31 @@ func CloseDB() error { return nil } +func GetGormDB(dbConfig DatabaseConfig, dbName string) (*gorm.DB, error) { + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + dbConfig.Host, dbConfig.Port, dbConfig.User, dbConfig.Password, dbName, dbConfig.SSLMode) + + // Configure GORM logger + gormLogger := logger.New( + log.New(log.Writer(), "\r\n", log.LstdFlags), // io writer + logger.Config{ + SlowThreshold: 200 * time.Millisecond, // Slow SQL threshold + LogLevel: dbConfig.LogLevel, // Log level + IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger + Colorful: true, // Enable color + }, + ) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: gormLogger, + }) + if err != nil { + return nil, fmt.Errorf("error connecting to the database: %w", err) + } + + return db, nil +} + // UpdateModel updates a model based on the set pointer fields func UpdateModel(ctx context.Context, model any, updates any) error { updateValue := reflect.ValueOf(updates) diff --git a/backend/internal/models/project.go b/backend/internal/models/project.go index e26b80d..fd78181 100644 --- a/backend/internal/models/project.go +++ b/backend/internal/models/project.go @@ -13,7 +13,7 @@ import ( type Project struct { EntityBase Name string `gorm:"column:name;not null"` - CustomerID ULIDWrapper `gorm:"column:customer_id;type:char(26);not null"` + CustomerID ULIDWrapper `gorm:"column:customer_id;type:bytea;not null"` // Relationships (for Eager Loading) Customer *Customer `gorm:"foreignKey:CustomerID"` diff --git a/backend/internal/models/timeentry.go b/backend/internal/models/timeentry.go index 41fbb3a..e8a4b1b 100644 --- a/backend/internal/models/timeentry.go +++ b/backend/internal/models/timeentry.go @@ -12,9 +12,9 @@ import ( // TimeEntry represents a time entry in the system type TimeEntry struct { EntityBase - 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"` + 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"` diff --git a/backend/internal/models/ulid_extension.go b/backend/internal/models/ulid_extension.go index 838345f..12e6cf9 100644 --- a/backend/internal/models/ulid_extension.go +++ b/backend/internal/models/ulid_extension.go @@ -10,14 +10,14 @@ import ( "gorm.io/gorm/clause" ) -// ULIDWrapper wraps ulid.ULID to allow method definitions +// ULIDWrapper wraps ulid.ULID to make it work nicely with GORM 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) +// 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 @@ -25,42 +25,30 @@ func FromULID(id ulid.ULID) ULIDWrapper { return ULIDWrapper{ULID: id} } -// From String creates a ULIDWrapper from a string +// 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 FromULID(parsed), nil + return ULIDWrapper{ULID: 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 +// 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 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) + return fmt.Errorf("failed to parse ULID: %w", err) } u.ULID = parsed return nil @@ -70,6 +58,20 @@ func (u *ULIDWrapper) Scan(src any) error { } // 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.String(), nil + return u.ULID.Bytes(), nil +} + +// GormValue implements the gorm.Valuer interface for ULIDWrapper +func (u ULIDWrapper) GormValue(ctx context.Context, db *gorm.DB) clause.Expr { + return clause.Expr{ + SQL: "?", + Vars: []any{u.Bytes()}, + } +} + +// Compare implements comparison for ULIDWrapper +func (u ULIDWrapper) Compare(other ULIDWrapper) int { + return u.ULID.Compare(other.ULID) } diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index ed913e4..57c9d85 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -39,7 +39,7 @@ type User struct { 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"` + CompanyID ULIDWrapper `gorm:"column:company_id;type:bytea;not null;index"` HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"` // Relationship for Eager Loading diff --git a/docu/database_schema.md b/docu/database_schema.md deleted file mode 100644 index 995fd0d..0000000 --- a/docu/database_schema.md +++ /dev/null @@ -1,194 +0,0 @@ -# Database Schema (PostgreSQL) - -```sql --- Multi-Tenant -CREATE TABLE companies ( - id UUID PRIMARY KEY, - name VARCHAR(255) NOT NULL, - address TEXT, - contact_email VARCHAR(255), - contact_phone VARCHAR(50), - logo_url TEXT, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - --- Go structs for creating and updating customers --- type CustomerCreate struct { --- Name string --- CompanyID int --- } - --- type CustomerUpdate struct { --- ID ulid.ULID --- Name *string --- CompanyID *int --- } - --- Go structs for creating and updating companies --- type CompanyCreate struct { --- Name string --- } - --- type CompanyUpdate struct { --- ID ulid.ULID --- Name *string --- } - --- Go structs for creating and updating activities --- type ActivityCreate struct { --- Name string --- BillingRate float64 --- } - --- type ActivityUpdate struct { --- ID ulid.ULID --- Name *string --- BillingRate *float64 --- } - --- Users and Roles -CREATE TABLE roles ( - id SERIAL PRIMARY KEY, - name VARCHAR(50) UNIQUE NOT NULL, - permissions JSONB -); - -CREATE TABLE users ( - id UUID PRIMARY KEY, - company_id UUID REFERENCES companies(id), - email VARCHAR(255) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - first_name VARCHAR(100), - last_name VARCHAR(100), - role_id INTEGER REFERENCES roles(id), - hourly_rate DECIMAL(10, 2), - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - --- Customers -CREATE TABLE customers ( - id UUID PRIMARY KEY, - company_id UUID NOT NULL REFERENCES companies(id), - name VARCHAR(255) NOT NULL, - contact_person VARCHAR(255), - email VARCHAR(255), - phone VARCHAR(50), - address TEXT, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - --- Projects -CREATE TABLE projects ( - id UUID PRIMARY KEY, - company_id UUID NOT NULL REFERENCES companies(id), - customer_id UUID REFERENCES customers(id), - name VARCHAR(255) NOT NULL, - description TEXT, - start_date DATE, - end_date DATE, - status VARCHAR(50), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - --- Activities -CREATE TABLE activities ( - id UUID PRIMARY KEY, - company_id UUID NOT NULL REFERENCES companies(id), - name VARCHAR(255) NOT NULL, - description TEXT, - billing_rate DECIMAL(10, 2), - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - --- Time bookings -CREATE TABLE time_entries ( - id UUID PRIMARY KEY, - company_id UUID NOT NULL REFERENCES companies(id), - user_id UUID NOT NULL REFERENCES users(id), - project_id UUID NOT NULL REFERENCES projects(id), - activity_id UUID NOT NULL REFERENCES activities(id), - start_time TIMESTAMP NOT NULL, - end_time TIMESTAMP NOT NULL, - duration INTEGER NOT NULL, -- in minutes - description TEXT, - billable_percentage INTEGER NOT NULL DEFAULT 100, - billing_rate DECIMAL(10, 2), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - --- Version 2: Sprint/Task Management -CREATE TABLE sprints ( - id UUID PRIMARY KEY, - project_id UUID NOT NULL REFERENCES projects(id), - name VARCHAR(255) NOT NULL, - start_date DATE NOT NULL, - end_date DATE NOT NULL, - status VARCHAR(50), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE TABLE task_statuses ( - id SERIAL PRIMARY KEY, - company_id UUID NOT NULL REFERENCES companies(id), - name VARCHAR(100) NOT NULL, - color VARCHAR(7), - position INTEGER NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE TABLE tasks ( - id UUID PRIMARY KEY, - company_id UUID NOT NULL REFERENCES companies(id), - project_id UUID NOT NULL REFERENCES projects(id), - sprint_id UUID REFERENCES sprints(id), - title VARCHAR(255) NOT NULL, - description TEXT, - assignee_id UUID REFERENCES users(id), - status_id INTEGER REFERENCES task_statuses(id), - priority VARCHAR(50), - estimate INTEGER, -- in minutes - due_date TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE TABLE kanban_boards ( - id UUID PRIMARY KEY, - company_id UUID NOT NULL REFERENCES companies(id), - project_id UUID NOT NULL REFERENCES projects(id), - name VARCHAR(255) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE TABLE kanban_columns ( - id UUID PRIMARY KEY, - board_id UUID NOT NULL REFERENCES kanban_boards(id), - name VARCHAR(100) NOT NULL, - position INTEGER NOT NULL, - task_status_id INTEGER REFERENCES task_statuses(id), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - --- Linking time entries and tasks -ALTER TABLE time_entries ADD COLUMN task_id UUID REFERENCES tasks(id); - --- Indexes for performance -CREATE INDEX idx_time_entries_user ON time_entries(user_id); -CREATE INDEX idx_time_entries_project ON time_entries(project_id); -CREATE INDEX idx_time_entries_date ON time_entries(start_time); -CREATE INDEX idx_projects_company ON projects(company_id); -CREATE INDEX idx_users_company ON users(company_id); -CREATE INDEX idx_tasks_project ON tasks(project_id); -CREATE INDEX idx_tasks_sprint ON tasks(sprint_id);