Compare commits

...

4 Commits

25 changed files with 377 additions and 363 deletions

View File

@ -1,49 +1,39 @@
# TimeTracker Project Rules (v2)
0. GENERAL
DONT OVERENGINEER.
USE IN LINE REPLACEMENTS IF POSSIBLE.
SOLVE TASKS AS FAST AS POSSIBLE. EACH REQUEST COSTS THE USER MONEY.
1. ARCHITECTURE
- Multi-tenancy enforced via company_id in all DB queries
- FPGO/FPTS patterns required for service layer implementations
2. CODING PRACTICES
- Type safety enforced (Go 1.21+ generics, TypeScript strict mode)
- Domain types must match across backend (Go) and frontend (TypeScript)
- All database access through repository interfaces
- API handlers must use DTOs for input/output
- Use tygo to generate TypeScript types after modifying Go types
3. SECURITY
- JWT authentication required for all API endpoints
- RBAC implemented in middleware/auth.go
- Input validation using github.com/go-playground/validator
- No raw SQL - use GORM query builder
4. DOCUMENTATION
- Swagger docs updated with all API changes
- Architecture decisions recorded in docu/ARCHITECTURE.md
- Type relationships documented in docu/domain_types.md
5. TESTING
- 80%+ test coverage for domain logic
- Integration tests for API endpoints
- Model tests in backend/cmd/modeltest
6. FRONTEND
- Next.js App Router pattern required
- Server components for data fetching
- Client components must use TanStack Query
- UI state management via Zustand
8. DEVELOPMENT WORKFLOW
- Makefile commands are only available in the backend folder
- Common make commands:
- make generate: Run code generation (tygo, swagger, etc.)
- make test: Run all tests
- make build: Build the application
- make run: Start the development server
9. CUSTOM RULES
- Add custom rules to .clinerules if:
- Unexpected behavior is encountered
- Specific conditions require warnings
- New patterns emerge that need documentation
- make run: Start the development server
- New patterns emerge that need documentation

8
.env
View File

@ -3,4 +3,10 @@ DB_PORT=5432
DB_USER=timetracker
DB_PASSWORD=password
DB_NAME=timetracker
DB_SSLMODE=disable
DB_SSLMODE=disable
API_KEY=
# JWT Configuration
JWT_SECRET=test
JWT_KEY_DIR=keys
JWT_KEY_GENERATE=true

View File

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

View File

@ -67,7 +67,7 @@ func main() {
r.GET("/api", helloHandler)
// Setup API routes
routes.SetupRouter(r)
routes.SetupRouter(r, cfg)
// Swagger documentation
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -246,7 +246,7 @@ func (h *UserHandler) Login(c *gin.Context) {
}
// Generate JWT token
token, err := middleware.GenerateToken(user)
token, err := middleware.GenerateToken(user, c)
if err != nil {
utils.InternalErrorResponse(c, "Error generating token: "+err.Error())
return
@ -292,7 +292,7 @@ func (h *UserHandler) Register(c *gin.Context) {
}
// Generate JWT token
token, err := middleware.GenerateToken(user)
token, err := middleware.GenerateToken(user, c)
if err != nil {
utils.InternalErrorResponse(c, "Error generating token: "+err.Error())
return

View File

@ -0,0 +1,35 @@
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/timetracker/backend/internal/api/utils"
"github.com/timetracker/backend/internal/config"
)
// APIKeyMiddleware checks for a valid API key if configured
func APIKeyMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip if no API key is configured
if cfg.APIKey == "" {
c.Next()
return
}
// Get API key from header
apiKey := c.GetHeader("X-API-Key")
if apiKey == "" {
utils.UnauthorizedResponse(c, "API key is required")
c.Abort()
return
}
// Validate API key
if apiKey != cfg.APIKey {
utils.UnauthorizedResponse(c, "Invalid API key")
c.Abort()
return
}
c.Next()
}
}

View File

@ -1,24 +1,97 @@
package middleware
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"strings"
"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"`
@ -31,23 +104,14 @@ type Claims struct {
// AuthMiddleware checks if the user is authenticated
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get the Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
utils.UnauthorizedResponse(c, "Authorization header is required")
// Get the token from cookie
tokenString, err := c.Cookie("jwt")
if err != nil {
utils.UnauthorizedResponse(c, "Authentication cookie is required")
c.Abort()
return
}
// Check if the header has the Bearer prefix
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
utils.UnauthorizedResponse(c, "Invalid authorization format, expected 'Bearer TOKEN'")
c.Abort()
return
}
tokenString := parts[1]
claims, err := validateToken(tokenString)
if err != nil {
utils.UnauthorizedResponse(c, "Invalid or expired token")
@ -102,7 +166,7 @@ func RoleMiddleware(roles ...string) gin.HandlerFunc {
}
// GenerateToken creates a new JWT token for a user
func GenerateToken(user *models.User) (string, error) {
func GenerateToken(user *models.User, c *gin.Context) (string, error) {
// Create the claims
claims := Claims{
UserID: user.ID.String(),
@ -125,6 +189,9 @@ func GenerateToken(user *models.User) (string, error) {
return "", err
}
// Set the cookie
c.SetCookie("jwt", tokenString, int(tokenDuration.Seconds()), "/", "", true, true)
return tokenString, nil
}

View File

@ -4,11 +4,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/timetracker/backend/internal/api/handlers"
"github.com/timetracker/backend/internal/api/middleware"
"github.com/timetracker/backend/internal/config"
)
// SetupRouter configures all the routes for the API
func SetupRouter(r *gin.Engine) {
func SetupRouter(r *gin.Engine, cfg *config.Config) {
// Create handlers
// Apply API key middleware to all API routes
r.Use(middleware.APIKeyMiddleware(cfg))
userHandler := handlers.NewUserHandler()
activityHandler := handlers.NewActivityHandler()
companyHandler := handlers.NewCompanyHandler()
@ -28,7 +31,7 @@ func SetupRouter(r *gin.Engine) {
// Protected routes
protected := api.Group("")
//protected.Use(middleware.AuthMiddleware())
protected.Use(middleware.AuthMiddleware())
{
// Auth routes (protected)
protectedAuth := protected.Group("/auth")

View File

@ -15,6 +15,7 @@ import (
// Config represents the application configuration
type Config struct {
Database models.DatabaseConfig
APIKey string
}
// LoadConfig loads configuration from environment variables and .env file
@ -31,6 +32,9 @@ func LoadConfig() (*Config, error) {
return nil, fmt.Errorf("failed to load database config: %w", err)
}
// Load API key
cfg.APIKey = getEnv("API_KEY", "")
return cfg, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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