Compare commits
No commits in common. "9057adebdd4bd2b4449604a7f64301465b243595" and "e336ff3ba2ef5e8031aa89b49724f34fdabc56ed" have entirely different histories.
9057adebdd
...
e336ff3ba2
22
.clinerules
22
.clinerules
@ -1,39 +1,49 @@
|
|||||||
# TimeTracker Project Rules (v2)
|
# 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
|
1. ARCHITECTURE
|
||||||
- Multi-tenancy enforced via company_id in all DB queries
|
- Multi-tenancy enforced via company_id in all DB queries
|
||||||
|
- FPGO/FPTS patterns required for service layer implementations
|
||||||
|
|
||||||
2. CODING PRACTICES
|
2. CODING PRACTICES
|
||||||
- Type safety enforced (Go 1.21+ generics, TypeScript strict mode)
|
- Type safety enforced (Go 1.21+ generics, TypeScript strict mode)
|
||||||
- Domain types must match across backend (Go) and frontend (TypeScript)
|
- Domain types must match across backend (Go) and frontend (TypeScript)
|
||||||
- All database access through repository interfaces
|
- All database access through repository interfaces
|
||||||
- API handlers must use DTOs for input/output
|
- API handlers must use DTOs for input/output
|
||||||
- Use tygo to generate TypeScript types after modifying Go types
|
- Use tygo to generate TypeScript types after modifying Go types
|
||||||
|
|
||||||
3. SECURITY
|
3. SECURITY
|
||||||
- JWT authentication required for all API endpoints
|
- JWT authentication required for all API endpoints
|
||||||
- RBAC implemented in middleware/auth.go
|
- RBAC implemented in middleware/auth.go
|
||||||
- Input validation using github.com/go-playground/validator
|
- Input validation using github.com/go-playground/validator
|
||||||
- No raw SQL - use GORM query builder
|
- No raw SQL - use GORM query builder
|
||||||
|
|
||||||
4. DOCUMENTATION
|
4. DOCUMENTATION
|
||||||
|
- Swagger docs updated with all API changes
|
||||||
- Architecture decisions recorded in docu/ARCHITECTURE.md
|
- Architecture decisions recorded in docu/ARCHITECTURE.md
|
||||||
- Type relationships documented in docu/domain_types.md
|
- Type relationships documented in docu/domain_types.md
|
||||||
|
|
||||||
5. TESTING
|
5. TESTING
|
||||||
- 80%+ test coverage for domain logic
|
- 80%+ test coverage for domain logic
|
||||||
- Integration tests for API endpoints
|
- Integration tests for API endpoints
|
||||||
- Model tests in backend/cmd/modeltest
|
- Model tests in backend/cmd/modeltest
|
||||||
|
|
||||||
6. FRONTEND
|
6. FRONTEND
|
||||||
- Next.js App Router pattern required
|
- 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
|
8. DEVELOPMENT WORKFLOW
|
||||||
- Makefile commands are only available in the backend folder
|
- Makefile commands are only available in the backend folder
|
||||||
- Common make commands:
|
- Common make commands:
|
||||||
- make generate: Run code generation (tygo, swagger, etc.)
|
- make generate: Run code generation (tygo, swagger, etc.)
|
||||||
- make test: Run all tests
|
- make test: Run all tests
|
||||||
- make build: Build the application
|
- make build: Build the application
|
||||||
- make run: Start the development server
|
|
||||||
9. CUSTOM RULES
|
9. CUSTOM RULES
|
||||||
- Add custom rules to .clinerules if:
|
- Add custom rules to .clinerules if:
|
||||||
- Unexpected behavior is encountered
|
- Unexpected behavior is encountered
|
||||||
- Specific conditions require warnings
|
- Specific conditions require warnings
|
||||||
- New patterns emerge that need documentation
|
- New patterns emerge that need documentation
|
||||||
|
- make run: Start the development server
|
8
.env
8
.env
@ -3,10 +3,4 @@ DB_PORT=5432
|
|||||||
DB_USER=timetracker
|
DB_USER=timetracker
|
||||||
DB_PASSWORD=password
|
DB_PASSWORD=password
|
||||||
DB_NAME=timetracker
|
DB_NAME=timetracker
|
||||||
DB_SSLMODE=disable
|
DB_SSLMODE=disable
|
||||||
API_KEY=
|
|
||||||
|
|
||||||
# JWT Configuration
|
|
||||||
JWT_SECRET=test
|
|
||||||
JWT_KEY_DIR=keys
|
|
||||||
JWT_KEY_GENERATE=true
|
|
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
# Time Tracker Backend Makefile
|
# Time Tracker Backend Makefile
|
||||||
|
|
||||||
.PHONY: db-start db-stop db-test model-test run build clean migrate seed help
|
.PHONY: db-start db-stop db-test model-test run build clean migrate seed help
|
||||||
@ -23,8 +24,6 @@ help:
|
|||||||
@echo " make clean - Remove build artifacts"
|
@echo " make clean - Remove build artifacts"
|
||||||
@echo " make migrate - Run database migrations"
|
@echo " make migrate - Run database migrations"
|
||||||
@echo " make seed - Seed the database with initial data"
|
@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"
|
@echo " make help - Show this help message"
|
||||||
|
|
||||||
# Start the database
|
# Start the database
|
||||||
@ -77,32 +76,3 @@ seed:
|
|||||||
@echo "Seeding the database..."
|
@echo "Seeding the database..."
|
||||||
@go run -mod=mod cmd/seed/main.go
|
@go run -mod=mod cmd/seed/main.go
|
||||||
@echo "Seeding complete"
|
@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"
|
|
||||||
|
@ -67,7 +67,7 @@ func main() {
|
|||||||
r.GET("/api", helloHandler)
|
r.GET("/api", helloHandler)
|
||||||
|
|
||||||
// Setup API routes
|
// Setup API routes
|
||||||
routes.SetupRouter(r, cfg)
|
routes.SetupRouter(r)
|
||||||
|
|
||||||
// Swagger documentation
|
// Swagger documentation
|
||||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
|
@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
@ -11,9 +10,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dropTable := flag.String("drop_table", "", "Drop the specified table")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
// Get database configuration with sensible defaults
|
// Get database configuration with sensible defaults
|
||||||
dbConfig := models.DefaultDatabaseConfig()
|
dbConfig := models.DefaultDatabaseConfig()
|
||||||
|
|
||||||
@ -38,19 +34,7 @@ func main() {
|
|||||||
|
|
||||||
// Test database connection with a simple query
|
// Test database connection with a simple query
|
||||||
var result int
|
var result int
|
||||||
var err error
|
err := db.Raw("SELECT 1").Scan(&result).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 {
|
if err != nil {
|
||||||
log.Fatalf("Error executing test query: %v", err)
|
log.Fatalf("Error executing test query: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@ -13,11 +12,6 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
// Parse command line flags
|
// Parse command line flags
|
||||||
verbose := false
|
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:] {
|
for _, arg := range os.Args[1:] {
|
||||||
if arg == "--verbose" || arg == "-v" {
|
if arg == "--verbose" || arg == "-v" {
|
||||||
verbose = true
|
verbose = true
|
||||||
@ -59,37 +53,7 @@ func main() {
|
|||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
fmt.Println("Connecting to 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)
|
log.Fatalf("Error initializing database: %v", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
@ -101,7 +65,7 @@ func main() {
|
|||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
fmt.Println("Running database 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)
|
log.Fatalf("Error migrating database: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Println("✓ Database migrations completed successfully")
|
fmt.Println("✓ Database migrations completed successfully")
|
||||||
|
@ -152,11 +152,7 @@ func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert DTO to model
|
// Convert DTO to model
|
||||||
customerCreate, err := convertCreateCustomerDTOToModel(customerCreateDTO)
|
customerCreate := convertCreateCustomerDTOToModel(customerCreateDTO)
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create customer in the database
|
// Create customer in the database
|
||||||
customer, err := models.CreateCustomer(c.Request.Context(), customerCreate)
|
customer, err := models.CreateCustomer(c.Request.Context(), customerCreate)
|
||||||
@ -207,11 +203,7 @@ func (h *CustomerHandler) UpdateCustomer(c *gin.Context) {
|
|||||||
customerUpdateDTO.ID = id.String()
|
customerUpdateDTO.ID = id.String()
|
||||||
|
|
||||||
// Convert DTO to model
|
// Convert DTO to model
|
||||||
customerUpdate, err := convertUpdateCustomerDTOToModel(customerUpdateDTO)
|
customerUpdate := convertUpdateCustomerDTOToModel(customerUpdateDTO)
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update customer in the database
|
// Update customer in the database
|
||||||
customer, err := models.UpdateCustomer(c.Request.Context(), customerUpdate)
|
customer, err := models.UpdateCustomer(c.Request.Context(), customerUpdate)
|
||||||
@ -272,32 +264,21 @@ func convertCustomerToDTO(customer *models.Customer) dto.CustomerDto {
|
|||||||
CreatedAt: customer.CreatedAt,
|
CreatedAt: customer.CreatedAt,
|
||||||
UpdatedAt: customer.UpdatedAt,
|
UpdatedAt: customer.UpdatedAt,
|
||||||
Name: customer.Name,
|
Name: customer.Name,
|
||||||
CompanyID: customer.CompanyID.String(),
|
CompanyID: customer.CompanyID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertCreateCustomerDTOToModel(dto dto.CustomerCreateDto) (models.CustomerCreate, error) {
|
func convertCreateCustomerDTOToModel(dto dto.CustomerCreateDto) models.CustomerCreate {
|
||||||
|
return models.CustomerCreate{
|
||||||
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,
|
Name: dto.Name,
|
||||||
CompanyID: companyID,
|
CompanyID: dto.CompanyID,
|
||||||
}
|
}
|
||||||
return create, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) (models.CustomerUpdate, error) {
|
func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) models.CustomerUpdate {
|
||||||
id, err := models.ULIDWrapperFromString(dto.ID)
|
id, _ := ulid.Parse(dto.ID)
|
||||||
if err != nil {
|
|
||||||
return models.CustomerUpdate{}, fmt.Errorf("invalid customer ID: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
update := models.CustomerUpdate{
|
update := models.CustomerUpdate{
|
||||||
ID: id,
|
ID: models.FromULID(id),
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.Name != nil {
|
if dto.Name != nil {
|
||||||
@ -305,14 +286,10 @@ func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) (models.Customer
|
|||||||
}
|
}
|
||||||
|
|
||||||
if dto.CompanyID != nil {
|
if dto.CompanyID != nil {
|
||||||
companyID, err := models.ULIDWrapperFromString(*dto.CompanyID)
|
update.CompanyID = dto.CompanyID
|
||||||
if err != nil {
|
|
||||||
return models.CustomerUpdate{}, fmt.Errorf("invalid company ID: %w", err)
|
|
||||||
}
|
|
||||||
update.CompanyID = &companyID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return update, nil
|
return update
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to parse company ID from string
|
// Helper function to parse company ID from string
|
||||||
|
@ -296,34 +296,36 @@ func (h *ProjectHandler) DeleteProject(c *gin.Context) {
|
|||||||
// Helper functions for DTO conversion
|
// Helper functions for DTO conversion
|
||||||
|
|
||||||
func convertProjectToDTO(project *models.Project) dto.ProjectDto {
|
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{
|
return dto.ProjectDto{
|
||||||
ID: project.ID.String(),
|
ID: project.ID.String(),
|
||||||
CreatedAt: project.CreatedAt,
|
CreatedAt: project.CreatedAt,
|
||||||
UpdatedAt: project.UpdatedAt,
|
UpdatedAt: project.UpdatedAt,
|
||||||
Name: project.Name,
|
Name: project.Name,
|
||||||
CustomerID: project.CustomerID.String(),
|
CustomerID: customerID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertCreateProjectDTOToModel(dto dto.ProjectCreateDto) (models.ProjectCreate, error) {
|
func convertCreateProjectDTOToModel(dto dto.ProjectCreateDto) (models.ProjectCreate, error) {
|
||||||
// Convert CustomerID from int to ULID (this is a simplification, adjust as needed)
|
// Convert CustomerID from int to ULID (this is a simplification, adjust as needed)
|
||||||
customerID, err := models.ULIDWrapperFromString(dto.CustomerID)
|
customerID, err := customerIDToULID(dto.CustomerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.ProjectCreate{}, fmt.Errorf("invalid customer ID: %w", err)
|
return models.ProjectCreate{}, fmt.Errorf("invalid customer ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return models.ProjectCreate{
|
return models.ProjectCreate{
|
||||||
Name: dto.Name,
|
Name: dto.Name,
|
||||||
CustomerID: customerID,
|
CustomerID: models.FromULID(customerID),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpdate, error) {
|
func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpdate, error) {
|
||||||
id, err := ulid.Parse(dto.ID)
|
id, _ := ulid.Parse(dto.ID)
|
||||||
if err != nil {
|
|
||||||
return models.ProjectUpdate{}, fmt.Errorf("invalid project ID: %w", err)
|
|
||||||
}
|
|
||||||
update := models.ProjectUpdate{
|
update := models.ProjectUpdate{
|
||||||
ID: models.FromULID(id),
|
ID: models.FromULID(id),
|
||||||
}
|
}
|
||||||
@ -334,12 +336,25 @@ func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpd
|
|||||||
|
|
||||||
if dto.CustomerID != nil {
|
if dto.CustomerID != nil {
|
||||||
// Convert CustomerID from int to ULID (this is a simplification, adjust as needed)
|
// Convert CustomerID from int to ULID (this is a simplification, adjust as needed)
|
||||||
customerID, err := models.ULIDWrapperFromString(*dto.CustomerID)
|
customerID, err := customerIDToULID(*dto.CustomerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.ProjectUpdate{}, fmt.Errorf("invalid customer ID: %w", err)
|
return models.ProjectUpdate{}, fmt.Errorf("invalid customer ID: %w", err)
|
||||||
}
|
}
|
||||||
update.CustomerID = &customerID
|
wrappedID := models.FromULID(customerID)
|
||||||
|
update.CustomerID = &wrappedID
|
||||||
}
|
}
|
||||||
|
|
||||||
return update, nil
|
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(),
|
ID: timeEntry.ID.String(),
|
||||||
CreatedAt: timeEntry.CreatedAt,
|
CreatedAt: timeEntry.CreatedAt,
|
||||||
UpdatedAt: timeEntry.UpdatedAt,
|
UpdatedAt: timeEntry.UpdatedAt,
|
||||||
UserID: timeEntry.UserID.String(), // Simplified conversion
|
UserID: int(timeEntry.UserID.Time()), // Simplified conversion
|
||||||
ProjectID: timeEntry.ProjectID.String(), // Simplified conversion
|
ProjectID: int(timeEntry.ProjectID.Time()), // Simplified conversion
|
||||||
ActivityID: timeEntry.ActivityID.String(), // Simplified conversion
|
ActivityID: int(timeEntry.ActivityID.Time()), // Simplified conversion
|
||||||
Start: timeEntry.Start,
|
Start: timeEntry.Start,
|
||||||
End: timeEntry.End,
|
End: timeEntry.End,
|
||||||
Description: timeEntry.Description,
|
Description: timeEntry.Description,
|
||||||
@ -418,25 +418,25 @@ func convertTimeEntryToDTO(timeEntry *models.TimeEntry) dto.TimeEntryDto {
|
|||||||
|
|
||||||
func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEntryCreate, error) {
|
func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEntryCreate, error) {
|
||||||
// Convert IDs from int to ULID (this is a simplification, adjust as needed)
|
// Convert IDs from int to ULID (this is a simplification, adjust as needed)
|
||||||
userID, err := models.ULIDWrapperFromString(dto.UserID)
|
userID, err := idToULID(dto.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryCreate{}, fmt.Errorf("invalid user ID: %w", err)
|
return models.TimeEntryCreate{}, fmt.Errorf("invalid user ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
projectID, err := models.ULIDWrapperFromString(dto.ProjectID)
|
projectID, err := idToULID(dto.ProjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryCreate{}, fmt.Errorf("invalid project ID: %w", err)
|
return models.TimeEntryCreate{}, fmt.Errorf("invalid project ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activityID, err := models.ULIDWrapperFromString(dto.ActivityID)
|
activityID, err := idToULID(dto.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryCreate{}, fmt.Errorf("invalid activity ID: %w", err)
|
return models.TimeEntryCreate{}, fmt.Errorf("invalid activity ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return models.TimeEntryCreate{
|
return models.TimeEntryCreate{
|
||||||
UserID: userID,
|
UserID: models.FromULID(userID),
|
||||||
ProjectID: projectID,
|
ProjectID: models.FromULID(projectID),
|
||||||
ActivityID: activityID,
|
ActivityID: models.FromULID(activityID),
|
||||||
Start: dto.Start,
|
Start: dto.Start,
|
||||||
End: dto.End,
|
End: dto.End,
|
||||||
Description: dto.Description,
|
Description: dto.Description,
|
||||||
@ -445,36 +445,36 @@ func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEn
|
|||||||
}
|
}
|
||||||
|
|
||||||
func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEntryUpdate, error) {
|
func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEntryUpdate, error) {
|
||||||
id, err := ulid.Parse(dto.ID)
|
id, _ := ulid.Parse(dto.ID)
|
||||||
if err != nil {
|
|
||||||
return models.TimeEntryUpdate{}, fmt.Errorf("invalid time entry ID: %w", err)
|
|
||||||
}
|
|
||||||
update := models.TimeEntryUpdate{
|
update := models.TimeEntryUpdate{
|
||||||
ID: models.FromULID(id),
|
ID: models.FromULID(id),
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.UserID != nil {
|
if dto.UserID != nil {
|
||||||
userID, err := models.ULIDWrapperFromString(*dto.UserID)
|
userID, err := idToULID(*dto.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err)
|
return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err)
|
||||||
}
|
}
|
||||||
update.UserID = &userID
|
wrappedID := models.FromULID(userID)
|
||||||
|
update.UserID = &wrappedID
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.ProjectID != nil {
|
if dto.ProjectID != nil {
|
||||||
projectID, err := models.ULIDWrapperFromString(*dto.ProjectID)
|
projectID, err := idToULID(*dto.ProjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err)
|
return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err)
|
||||||
}
|
}
|
||||||
update.ProjectID = &projectID
|
wrappedProjectID := models.FromULID(projectID)
|
||||||
|
update.ProjectID = &wrappedProjectID
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.ActivityID != nil {
|
if dto.ActivityID != nil {
|
||||||
activityID, err := models.ULIDWrapperFromString(*dto.ActivityID)
|
activityID, err := idToULID(*dto.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err)
|
return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err)
|
||||||
}
|
}
|
||||||
update.ActivityID = &activityID
|
wrappedActivityID := models.FromULID(activityID)
|
||||||
|
update.ActivityID = &wrappedActivityID
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.Start != nil {
|
if dto.Start != nil {
|
||||||
@ -495,3 +495,13 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn
|
|||||||
|
|
||||||
return update, nil
|
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
|
||||||
|
}
|
||||||
|
@ -246,7 +246,7 @@ func (h *UserHandler) Login(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
token, err := middleware.GenerateToken(user, c)
|
token, err := middleware.GenerateToken(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error generating token: "+err.Error())
|
utils.InternalErrorResponse(c, "Error generating token: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -292,7 +292,7 @@ func (h *UserHandler) Register(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
token, err := middleware.GenerateToken(user, c)
|
token, err := middleware.GenerateToken(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error generating token: "+err.Error())
|
utils.InternalErrorResponse(c, "Error generating token: "+err.Error())
|
||||||
return
|
return
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,97 +1,24 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/pem"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"strings"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/oklog/ulid/v2"
|
||||||
"github.com/timetracker/backend/internal/api/utils"
|
"github.com/timetracker/backend/internal/api/utils"
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// JWT configuration
|
||||||
jwtSecret string
|
const (
|
||||||
|
// This should be moved to environment variables in production
|
||||||
|
jwtSecret = "your-secret-key-change-in-production"
|
||||||
tokenDuration = 24 * time.Hour
|
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
|
// Claims represents the JWT claims
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
@ -104,14 +31,23 @@ type Claims struct {
|
|||||||
// AuthMiddleware checks if the user is authenticated
|
// AuthMiddleware checks if the user is authenticated
|
||||||
func AuthMiddleware() gin.HandlerFunc {
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Get the token from cookie
|
// Get the Authorization header
|
||||||
tokenString, err := c.Cookie("jwt")
|
authHeader := c.GetHeader("Authorization")
|
||||||
if err != nil {
|
if authHeader == "" {
|
||||||
utils.UnauthorizedResponse(c, "Authentication cookie is required")
|
utils.UnauthorizedResponse(c, "Authorization header is required")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
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)
|
claims, err := validateToken(tokenString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.UnauthorizedResponse(c, "Invalid or expired token")
|
utils.UnauthorizedResponse(c, "Invalid or expired token")
|
||||||
@ -166,7 +102,7 @@ func RoleMiddleware(roles ...string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GenerateToken creates a new JWT token for a user
|
// GenerateToken creates a new JWT token for a user
|
||||||
func GenerateToken(user *models.User, c *gin.Context) (string, error) {
|
func GenerateToken(user *models.User) (string, error) {
|
||||||
// Create the claims
|
// Create the claims
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
UserID: user.ID.String(),
|
UserID: user.ID.String(),
|
||||||
@ -189,9 +125,6 @@ func GenerateToken(user *models.User, c *gin.Context) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the cookie
|
|
||||||
c.SetCookie("jwt", tokenString, int(tokenDuration.Seconds()), "/", "", true, true)
|
|
||||||
|
|
||||||
return tokenString, nil
|
return tokenString, nil
|
||||||
}
|
}
|
||||||
|
|
@ -4,14 +4,11 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/timetracker/backend/internal/api/handlers"
|
"github.com/timetracker/backend/internal/api/handlers"
|
||||||
"github.com/timetracker/backend/internal/api/middleware"
|
"github.com/timetracker/backend/internal/api/middleware"
|
||||||
"github.com/timetracker/backend/internal/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupRouter configures all the routes for the API
|
// SetupRouter configures all the routes for the API
|
||||||
func SetupRouter(r *gin.Engine, cfg *config.Config) {
|
func SetupRouter(r *gin.Engine) {
|
||||||
// Create handlers
|
// Create handlers
|
||||||
// Apply API key middleware to all API routes
|
|
||||||
r.Use(middleware.APIKeyMiddleware(cfg))
|
|
||||||
userHandler := handlers.NewUserHandler()
|
userHandler := handlers.NewUserHandler()
|
||||||
activityHandler := handlers.NewActivityHandler()
|
activityHandler := handlers.NewActivityHandler()
|
||||||
companyHandler := handlers.NewCompanyHandler()
|
companyHandler := handlers.NewCompanyHandler()
|
||||||
@ -31,7 +28,7 @@ func SetupRouter(r *gin.Engine, cfg *config.Config) {
|
|||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
protected := api.Group("")
|
protected := api.Group("")
|
||||||
protected.Use(middleware.AuthMiddleware())
|
//protected.Use(middleware.AuthMiddleware())
|
||||||
{
|
{
|
||||||
// Auth routes (protected)
|
// Auth routes (protected)
|
||||||
protectedAuth := protected.Group("/auth")
|
protectedAuth := protected.Group("/auth")
|
||||||
|
@ -15,7 +15,6 @@ import (
|
|||||||
// Config represents the application configuration
|
// Config represents the application configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Database models.DatabaseConfig
|
Database models.DatabaseConfig
|
||||||
APIKey string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig loads configuration from environment variables and .env file
|
// LoadConfig loads configuration from environment variables and .env file
|
||||||
@ -32,9 +31,6 @@ func LoadConfig() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("failed to load database config: %w", err)
|
return nil, fmt.Errorf("failed to load database config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load API key
|
|
||||||
cfg.APIKey = getEnv("API_KEY", "")
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,12 +10,12 @@ type CustomerDto struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
LastEditorID string `json:"lastEditorID"`
|
LastEditorID string `json:"lastEditorID"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CompanyID string `json:"companyId"`
|
CompanyID int `json:"companyId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomerCreateDto struct {
|
type CustomerCreateDto struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CompanyID string `json:"companyId"`
|
CompanyID int `json:"companyId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomerUpdateDto struct {
|
type CustomerUpdateDto struct {
|
||||||
@ -24,5 +24,5 @@ type CustomerUpdateDto struct {
|
|||||||
UpdatedAt *time.Time `json:"updatedAt"`
|
UpdatedAt *time.Time `json:"updatedAt"`
|
||||||
LastEditorID *string `json:"lastEditorID"`
|
LastEditorID *string `json:"lastEditorID"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
CompanyID *string `json:"companyId"`
|
CompanyID *int `json:"companyId"`
|
||||||
}
|
}
|
||||||
|
@ -10,12 +10,12 @@ type ProjectDto struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
LastEditorID string `json:"lastEditorID"`
|
LastEditorID string `json:"lastEditorID"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CustomerID string `json:"customerId"`
|
CustomerID int `json:"customerId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectCreateDto struct {
|
type ProjectCreateDto struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CustomerID string `json:"customerId"`
|
CustomerID int `json:"customerId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectUpdateDto struct {
|
type ProjectUpdateDto struct {
|
||||||
@ -24,5 +24,5 @@ type ProjectUpdateDto struct {
|
|||||||
UpdatedAt *time.Time `json:"updatedAt"`
|
UpdatedAt *time.Time `json:"updatedAt"`
|
||||||
LastEditorID *string `json:"lastEditorID"`
|
LastEditorID *string `json:"lastEditorID"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
CustomerID *string `json:"customerId"`
|
CustomerID *int `json:"customerId"`
|
||||||
}
|
}
|
||||||
|
@ -9,9 +9,9 @@ type TimeEntryDto struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
LastEditorID string `json:"lastEditorID"`
|
LastEditorID string `json:"lastEditorID"`
|
||||||
UserID string `json:"userId"`
|
UserID int `json:"userId"`
|
||||||
ProjectID string `json:"projectId"`
|
ProjectID int `json:"projectId"`
|
||||||
ActivityID string `json:"activityId"`
|
ActivityID int `json:"activityId"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@ -19,9 +19,9 @@ type TimeEntryDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TimeEntryCreateDto struct {
|
type TimeEntryCreateDto struct {
|
||||||
UserID string `json:"userId"`
|
UserID int `json:"userId"`
|
||||||
ProjectID string `json:"projectId"`
|
ProjectID int `json:"projectId"`
|
||||||
ActivityID string `json:"activityId"`
|
ActivityID int `json:"activityId"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@ -33,9 +33,9 @@ type TimeEntryUpdateDto struct {
|
|||||||
CreatedAt *time.Time `json:"createdAt"`
|
CreatedAt *time.Time `json:"createdAt"`
|
||||||
UpdatedAt *time.Time `json:"updatedAt"`
|
UpdatedAt *time.Time `json:"updatedAt"`
|
||||||
LastEditorID *string `json:"lastEditorID"`
|
LastEditorID *string `json:"lastEditorID"`
|
||||||
UserID *string `json:"userId"`
|
UserID *int `json:"userId"`
|
||||||
ProjectID *string `json:"projectId"`
|
ProjectID *int `json:"projectId"`
|
||||||
ActivityID *string `json:"activityId"`
|
ActivityID *int `json:"activityId"`
|
||||||
Start *time.Time `json:"start"`
|
Start *time.Time `json:"start"`
|
||||||
End *time.Time `json:"end"`
|
End *time.Time `json:"end"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
|
@ -3,6 +3,7 @@ package models
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"runtime/debug"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/oklog/ulid/v2"
|
||||||
@ -10,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type EntityBase struct {
|
type EntityBase struct {
|
||||||
ID ULIDWrapper `gorm:"type:bytea;primaryKey"`
|
ID ULIDWrapper `gorm:"type:char(26);primaryKey"`
|
||||||
CreatedAt time.Time `gorm:"index"`
|
CreatedAt time.Time `gorm:"index"`
|
||||||
UpdatedAt time.Time `gorm:"index"`
|
UpdatedAt time.Time `gorm:"index"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
@ -18,6 +19,9 @@ type EntityBase struct {
|
|||||||
|
|
||||||
// BeforeCreate is called by GORM before creating a record
|
// BeforeCreate is called by GORM before creating a record
|
||||||
func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error {
|
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
|
if eb.ID.Compare(ULIDWrapper{}) == 0 { // If ID is empty
|
||||||
// Generate a new ULID
|
// Generate a new ULID
|
||||||
entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0)
|
entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0)
|
||||||
|
@ -10,8 +10,8 @@ import (
|
|||||||
// Customer represents a customer in the system
|
// Customer represents a customer in the system
|
||||||
type Customer struct {
|
type Customer struct {
|
||||||
EntityBase
|
EntityBase
|
||||||
Name string `gorm:"column:name"`
|
Name string `gorm:"column:name"`
|
||||||
CompanyID ULIDWrapper `gorm:"type:bytea;column:company_id"`
|
CompanyID int `gorm:"column:company_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName specifies the table name for GORM
|
// TableName specifies the table name for GORM
|
||||||
@ -22,14 +22,14 @@ func (Customer) TableName() string {
|
|||||||
// CustomerCreate contains the fields for creating a new customer
|
// CustomerCreate contains the fields for creating a new customer
|
||||||
type CustomerCreate struct {
|
type CustomerCreate struct {
|
||||||
Name string
|
Name string
|
||||||
CompanyID ULIDWrapper
|
CompanyID int
|
||||||
}
|
}
|
||||||
|
|
||||||
// CustomerUpdate contains the updatable fields of a customer
|
// CustomerUpdate contains the updatable fields of a customer
|
||||||
type CustomerUpdate struct {
|
type CustomerUpdate struct {
|
||||||
ID ULIDWrapper `gorm:"-"` // Exclude from updates
|
ID ULIDWrapper `gorm:"-"` // Exclude from updates
|
||||||
Name *string `gorm:"column:name"`
|
Name *string `gorm:"column:name"`
|
||||||
CompanyID *ULIDWrapper `gorm:"column:company_id"`
|
CompanyID *int `gorm:"column:company_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCustomerByID finds a customer by its ID
|
// GetCustomerByID finds a customer by its ID
|
||||||
|
@ -141,31 +141,6 @@ func CloseDB() error {
|
|||||||
return nil
|
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
|
// UpdateModel updates a model based on the set pointer fields
|
||||||
func UpdateModel(ctx context.Context, model any, updates any) error {
|
func UpdateModel(ctx context.Context, model any, updates any) error {
|
||||||
updateValue := reflect.ValueOf(updates)
|
updateValue := reflect.ValueOf(updates)
|
||||||
|
@ -13,7 +13,7 @@ import (
|
|||||||
type Project struct {
|
type Project struct {
|
||||||
EntityBase
|
EntityBase
|
||||||
Name string `gorm:"column:name;not null"`
|
Name string `gorm:"column:name;not null"`
|
||||||
CustomerID ULIDWrapper `gorm:"column:customer_id;type:bytea;not null"`
|
CustomerID ULIDWrapper `gorm:"column:customer_id;type:char(26);not null"`
|
||||||
|
|
||||||
// Relationships (for Eager Loading)
|
// Relationships (for Eager Loading)
|
||||||
Customer *Customer `gorm:"foreignKey:CustomerID"`
|
Customer *Customer `gorm:"foreignKey:CustomerID"`
|
||||||
|
@ -12,9 +12,9 @@ import (
|
|||||||
// TimeEntry represents a time entry in the system
|
// TimeEntry represents a time entry in the system
|
||||||
type TimeEntry struct {
|
type TimeEntry struct {
|
||||||
EntityBase
|
EntityBase
|
||||||
UserID ULIDWrapper `gorm:"column:user_id;type:bytea;not null;index"`
|
UserID ULIDWrapper `gorm:"column:user_id;type:char(26);not null;index"`
|
||||||
ProjectID ULIDWrapper `gorm:"column:project_id;type:bytea;not null;index"`
|
ProjectID ULIDWrapper `gorm:"column:project_id;type:char(26);not null;index"`
|
||||||
ActivityID ULIDWrapper `gorm:"column:activity_id;type:bytea;not null;index"`
|
ActivityID ULIDWrapper `gorm:"column:activity_id;type:char(26);not null;index"`
|
||||||
Start time.Time `gorm:"column:start;not null"`
|
Start time.Time `gorm:"column:start;not null"`
|
||||||
End time.Time `gorm:"column:end;not null"`
|
End time.Time `gorm:"column:end;not null"`
|
||||||
Description string `gorm:"column:description"`
|
Description string `gorm:"column:description"`
|
||||||
|
@ -10,14 +10,14 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ULIDWrapper wraps ulid.ULID to make it work nicely with GORM
|
// ULIDWrapper wraps ulid.ULID to allow method definitions
|
||||||
type ULIDWrapper struct {
|
type ULIDWrapper struct {
|
||||||
ulid.ULID
|
ulid.ULID
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewULIDWrapper creates a new ULIDWrapper with a new ULID
|
// Compare implements the same comparison method as ulid.ULID
|
||||||
func NewULIDWrapper() ULIDWrapper {
|
func (u ULIDWrapper) Compare(other ULIDWrapper) int {
|
||||||
return ULIDWrapper{ULID: ulid.Make()}
|
return u.ULID.Compare(other.ULID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromULID creates a ULIDWrapper from a ulid.ULID
|
// FromULID creates a ULIDWrapper from a ulid.ULID
|
||||||
@ -25,30 +25,42 @@ func FromULID(id ulid.ULID) ULIDWrapper {
|
|||||||
return ULIDWrapper{ULID: id}
|
return ULIDWrapper{ULID: id}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ULIDWrapperFromString creates a ULIDWrapper from a string
|
// From String creates a ULIDWrapper from a string
|
||||||
func ULIDWrapperFromString(id string) (ULIDWrapper, error) {
|
func ULIDWrapperFromString(id string) (ULIDWrapper, error) {
|
||||||
parsed, err := ulid.Parse(id)
|
parsed, err := ulid.Parse(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ULIDWrapper{}, fmt.Errorf("failed to parse ULID string: %w", err)
|
return ULIDWrapper{}, fmt.Errorf("failed to parse ULID string: %w", err)
|
||||||
}
|
}
|
||||||
return ULIDWrapper{ULID: parsed}, nil
|
return FromULID(parsed), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan implements the sql.Scanner interface for ULIDWrapper
|
// 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
|
||||||
func (u *ULIDWrapper) Scan(src any) error {
|
func (u *ULIDWrapper) Scan(src any) error {
|
||||||
switch v := src.(type) {
|
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:
|
case string:
|
||||||
parsed, err := ulid.Parse(v)
|
parsed, err := ulid.Parse(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse ULID: %w", err)
|
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)
|
||||||
}
|
}
|
||||||
u.ULID = parsed
|
u.ULID = parsed
|
||||||
return nil
|
return nil
|
||||||
@ -58,20 +70,6 @@ func (u *ULIDWrapper) Scan(src any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Value implements the driver.Valuer interface for ULIDWrapper
|
// 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) {
|
func (u ULIDWrapper) Value() (driver.Value, error) {
|
||||||
return u.ULID.Bytes(), nil
|
return u.String(), 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
|
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
|
Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash
|
||||||
Role string `gorm:"column:role;not null;default:'user'"`
|
Role string `gorm:"column:role;not null;default:'user'"`
|
||||||
CompanyID ULIDWrapper `gorm:"column:company_id;type:bytea;not null;index"`
|
CompanyID ULIDWrapper `gorm:"column:company_id;type:char(26);not null;index"`
|
||||||
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
||||||
|
|
||||||
// Relationship for Eager Loading
|
// Relationship for Eager Loading
|
||||||
|
194
docu/database_schema.md
Normal file
194
docu/database_schema.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# 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