feat: Update database models and DTOs to use bytea for ULIDWrapper and add JWT configuration to environment
This commit is contained in:
parent
c08da6fc92
commit
9057adebdd
7
.env
7
.env
@ -4,4 +4,9 @@ DB_USER=timetracker
|
||||
DB_PASSWORD=password
|
||||
DB_NAME=timetracker
|
||||
DB_SSLMODE=disable
|
||||
API_KEY=
|
||||
API_KEY=
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=test
|
||||
JWT_KEY_DIR=keys
|
||||
JWT_KEY_GENERATE=true
|
@ -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"
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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"`
|
@ -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"`
|
||||
}
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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"`
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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"`
|
||||
|
@ -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"`
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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);
|
Loading…
x
Reference in New Issue
Block a user