Compare commits
4 Commits
21c9233058
...
4b47da3673
Author | SHA1 | Date | |
---|---|---|---|
4b47da3673 | |||
a9c7598862 | |||
bcc3aadb85 | |||
fcdeedf7e9 |
@ -7,6 +7,8 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/models"
|
||||
)
|
||||
|
||||
@ -15,15 +17,15 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Get database configuration with sensible defaults
|
||||
dbConfig := models.DefaultDatabaseConfig()
|
||||
dbConfig := config.DefaultDatabaseConfig()
|
||||
|
||||
// Initialize database
|
||||
fmt.Println("Connecting to database...")
|
||||
if err := models.InitDB(dbConfig); err != nil {
|
||||
if err := db.InitDB(dbConfig); err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := models.CloseDB(); err != nil {
|
||||
if err := db.CloseDB(); err != nil {
|
||||
log.Printf("Error closing database connection: %v", err)
|
||||
}
|
||||
}()
|
||||
@ -34,7 +36,7 @@ func main() {
|
||||
defer cancel()
|
||||
|
||||
// Get the database engine
|
||||
db := models.GetEngine(ctx)
|
||||
db := db.GetEngine(ctx)
|
||||
|
||||
// Test database connection with a simple query
|
||||
var result int
|
||||
|
@ -6,6 +6,8 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/models"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@ -29,7 +31,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Get database configuration with sensible defaults
|
||||
dbConfig := models.DefaultDatabaseConfig()
|
||||
dbConfig := config.DefaultDatabaseConfig()
|
||||
|
||||
// Override with environment variables if provided
|
||||
if host := os.Getenv("DB_HOST"); host != "" {
|
||||
@ -62,7 +64,7 @@ func main() {
|
||||
|
||||
var err error
|
||||
|
||||
gormDB, err := models.GetGormDB(dbConfig, "postgres")
|
||||
gormDB, err := db.GetGormDB(dbConfig, "postgres")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting gorm DB: %v", err)
|
||||
}
|
||||
@ -89,11 +91,11 @@ func main() {
|
||||
fmt.Printf("✓ Database %s created successfully\n", dbConfig.DBName)
|
||||
}
|
||||
|
||||
if err = models.InitDB(dbConfig); err != nil {
|
||||
if err = db.InitDB(dbConfig); err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := models.CloseDB(); err != nil {
|
||||
if err := db.CloseDB(); err != nil {
|
||||
log.Printf("Error closing database connection: %v", err)
|
||||
}
|
||||
}()
|
||||
|
@ -7,21 +7,23 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/models"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Get database configuration with sensible defaults
|
||||
dbConfig := models.DefaultDatabaseConfig()
|
||||
dbConfig := config.DefaultDatabaseConfig()
|
||||
|
||||
// Initialize database
|
||||
fmt.Println("Connecting to database...")
|
||||
if err := models.InitDB(dbConfig); err != nil {
|
||||
if err := db.InitDB(dbConfig); err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := models.CloseDB(); err != nil {
|
||||
if err := db.CloseDB(); err != nil {
|
||||
log.Printf("Error closing database connection: %v", err)
|
||||
}
|
||||
}()
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -23,11 +24,11 @@ func main() {
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
if err := models.InitDB(cfg.Database); err != nil {
|
||||
if err := db.InitDB(cfg.Database); err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := models.CloseDB(); err != nil {
|
||||
if err := db.CloseDB(); err != nil {
|
||||
log.Printf("Error closing database connection: %v", err)
|
||||
}
|
||||
}()
|
||||
@ -44,7 +45,7 @@ func main() {
|
||||
func seedDatabase(ctx context.Context) error {
|
||||
// Check if seeding is needed
|
||||
var count int64
|
||||
if err := models.GetEngine(ctx).Model(&models.Company{}).Count(&count).Error; err != nil {
|
||||
if err := db.GetEngine(ctx).Model(&models.Company{}).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("error checking if seeding is needed: %w", err)
|
||||
}
|
||||
|
||||
@ -57,7 +58,7 @@ func seedDatabase(ctx context.Context) error {
|
||||
log.Println("Seeding database with initial data...")
|
||||
|
||||
// Start transaction
|
||||
return models.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Create default company
|
||||
defaultCompany := models.Company{
|
||||
Name: "Default Company",
|
||||
|
@ -12,7 +12,7 @@ type ProjectDto struct {
|
||||
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
Name string `json:"name" example:"Time Tracking App"`
|
||||
CustomerID *string `json:"customerId" example:"01HGW2BBG0000000000000000"`
|
||||
CustomerID *string `json:"customerId,omitempty" example:"01HGW2BBG0000000000000000"`
|
||||
}
|
||||
|
||||
type ProjectCreateDto struct {
|
||||
|
@ -148,13 +148,17 @@ func (h *ProjectHandler) DeleteProject(c *gin.Context) {
|
||||
// Helper functions for DTO conversion
|
||||
|
||||
func convertProjectToDTO(project *models.Project) dto.ProjectDto {
|
||||
customerId := project.CustomerID.String()
|
||||
var customerIdPtr *string
|
||||
if project.CustomerID != nil {
|
||||
customerIdStr := project.CustomerID.String()
|
||||
customerIdPtr = &customerIdStr
|
||||
}
|
||||
return dto.ProjectDto{
|
||||
ID: project.ID.String(),
|
||||
CreatedAt: project.CreatedAt,
|
||||
UpdatedAt: project.UpdatedAt,
|
||||
Name: project.Name,
|
||||
CustomerID: &customerId,
|
||||
CustomerID: customerIdPtr,
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,13 +186,13 @@ func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto, id types.ULID) (mo
|
||||
|
||||
if dto.CustomerID.Valid {
|
||||
if dto.CustomerID.Value == nil {
|
||||
update.CustomerID = nil
|
||||
update.CustomerID = types.Null[types.ULID]()
|
||||
} else {
|
||||
customerID, err := types.ULIDFromString(*dto.CustomerID.Value)
|
||||
if err != nil {
|
||||
return models.ProjectUpdate{}, fmt.Errorf("invalid customer ID: %w", err)
|
||||
}
|
||||
update.CustomerID = &customerID
|
||||
update.CustomerID = types.NewNullable(customerID)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,7 +73,7 @@ func fileExists(path string) bool {
|
||||
}
|
||||
|
||||
// generateRSAKeys generates RSA keys and saves them to disk
|
||||
func generateRSAKeys(cfg models.JWTConfig) error {
|
||||
func generateRSAKeys(cfg config.JWTConfig) error {
|
||||
// Create key directory if it doesn't exist
|
||||
if err := os.MkdirAll(cfg.KeyDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create key directory: %w", err)
|
||||
|
@ -9,14 +9,54 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/timetracker/backend/internal/models"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// DatabaseConfig contains the configuration data for the database connection
|
||||
type DatabaseConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
DBName string
|
||||
SSLMode string
|
||||
MaxIdleConns int // Maximum number of idle connections
|
||||
MaxOpenConns int // Maximum number of open connections
|
||||
MaxLifetime time.Duration // Maximum lifetime of a connection
|
||||
LogLevel logger.LogLevel
|
||||
}
|
||||
|
||||
// DefaultDatabaseConfig returns a default configuration with sensible values
|
||||
func DefaultDatabaseConfig() DatabaseConfig {
|
||||
return DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "timetracker",
|
||||
Password: "password",
|
||||
DBName: "timetracker",
|
||||
SSLMode: "disable",
|
||||
MaxIdleConns: 10,
|
||||
MaxOpenConns: 100,
|
||||
MaxLifetime: time.Hour,
|
||||
LogLevel: logger.Info,
|
||||
}
|
||||
}
|
||||
|
||||
// JWTConfig represents the configuration for JWT authentication
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
TokenDuration time.Duration
|
||||
KeyGenerate bool
|
||||
KeyDir string
|
||||
PrivKeyFile string
|
||||
PubKeyFile string
|
||||
KeyBits int
|
||||
}
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Database models.DatabaseConfig
|
||||
JWTConfig models.JWTConfig
|
||||
Database DatabaseConfig
|
||||
JWTConfig JWTConfig
|
||||
APIKey string
|
||||
}
|
||||
|
||||
@ -26,8 +66,8 @@ func LoadConfig() (*Config, error) {
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := &Config{
|
||||
Database: models.DefaultDatabaseConfig(),
|
||||
JWTConfig: models.JWTConfig{},
|
||||
Database: DefaultDatabaseConfig(),
|
||||
JWTConfig: JWTConfig{},
|
||||
}
|
||||
|
||||
// Load database configuration
|
||||
|
126
backend/internal/db/db.go
Normal file
126
backend/internal/db/db.go
Normal file
@ -0,0 +1,126 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Global variable for the DB connection
|
||||
var db *gorm.DB
|
||||
|
||||
// ErrDBNotInitialized is returned when a database operation is attempted before initialization
|
||||
var ErrDBNotInitialized = errors.New("database not initialized")
|
||||
|
||||
// InitDB initializes the database connection (once at startup)
|
||||
// with the provided configuration
|
||||
func InitDB(config config.DatabaseConfig) error {
|
||||
// Create connection using the default database name
|
||||
gormDB, err := createConnection(config, config.DBName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the global db instance
|
||||
db = gormDB
|
||||
|
||||
// Configure connection pool
|
||||
return configureConnectionPool(db, config)
|
||||
}
|
||||
|
||||
// GetEngine returns the DB instance with context
|
||||
func GetEngine(ctx context.Context) *gorm.DB {
|
||||
if db == nil {
|
||||
panic(ErrDBNotInitialized)
|
||||
}
|
||||
return db.WithContext(ctx)
|
||||
}
|
||||
|
||||
// CloseDB closes the database connection
|
||||
func CloseDB() error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting database connection: %w", err)
|
||||
}
|
||||
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
return fmt.Errorf("error closing database connection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGormDB is used for special cases like database creation
|
||||
func GetGormDB(dbConfig config.DatabaseConfig, dbName string) (*gorm.DB, error) {
|
||||
return createConnection(dbConfig, dbName)
|
||||
}
|
||||
|
||||
// MigrateDB performs database migrations for all models
|
||||
// This is a placeholder that will be called by models.MigrateDB
|
||||
func MigrateDB() error {
|
||||
if db == nil {
|
||||
return ErrDBNotInitialized
|
||||
}
|
||||
// The actual migration is implemented in models.MigrateDB
|
||||
// This is just a placeholder to make the migrate/main.go file work
|
||||
return errors.New("MigrateDB should be called from models package")
|
||||
}
|
||||
|
||||
// createConnection creates a new database connection with the given configuration
|
||||
func createConnection(dbConfig config.DatabaseConfig, dbName string) (*gorm.DB, error) {
|
||||
// Create DSN (Data Source Name)
|
||||
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 := createGormLogger(dbConfig)
|
||||
|
||||
// Establish database connection with custom logger
|
||||
gormDB, 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 gormDB, nil
|
||||
}
|
||||
|
||||
// createGormLogger creates a configured GORM logger instance
|
||||
func createGormLogger(dbConfig config.DatabaseConfig) logger.Interface {
|
||||
return 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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// configureConnectionPool sets up the connection pool parameters
|
||||
func configureConnectionPool(db *gorm.DB, config config.DatabaseConfig) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting database connection: %w", err)
|
||||
}
|
||||
|
||||
// Set connection pool parameters
|
||||
sqlDB.SetMaxIdleConns(config.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(config.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(config.MaxLifetime)
|
||||
|
||||
return nil
|
||||
}
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -36,7 +37,7 @@ type ActivityCreate struct {
|
||||
// GetActivityByID finds an Activity by its ID
|
||||
func GetActivityByID(ctx context.Context, id types.ULID) (*Activity, error) {
|
||||
var activity Activity
|
||||
result := GetEngine(ctx).Where("id = ?", id).First(&activity)
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&activity)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@ -49,7 +50,7 @@ func GetActivityByID(ctx context.Context, id types.ULID) (*Activity, error) {
|
||||
// GetAllActivities returns all Activities
|
||||
func GetAllActivities(ctx context.Context) ([]Activity, error) {
|
||||
var activities []Activity
|
||||
result := GetEngine(ctx).Find(&activities)
|
||||
result := db.GetEngine(ctx).Find(&activities)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -63,7 +64,7 @@ func CreateActivity(ctx context.Context, create ActivityCreate) (*Activity, erro
|
||||
BillingRate: create.BillingRate,
|
||||
}
|
||||
|
||||
result := GetEngine(ctx).Create(&activity)
|
||||
result := db.GetEngine(ctx).Create(&activity)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -91,6 +92,6 @@ func UpdateActivity(ctx context.Context, update ActivityUpdate) (*Activity, erro
|
||||
|
||||
// DeleteActivity deletes an Activity by its ID
|
||||
func DeleteActivity(ctx context.Context, id types.ULID) error {
|
||||
result := GetEngine(ctx).Delete(&Activity{}, id)
|
||||
result := db.GetEngine(ctx).Delete(&Activity{}, id)
|
||||
return result.Error
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -33,7 +34,7 @@ type CompanyUpdate struct {
|
||||
// GetCompanyByID finds a company by its ID
|
||||
func GetCompanyByID(ctx context.Context, id types.ULID) (*Company, error) {
|
||||
var company Company
|
||||
result := GetEngine(ctx).Where("id = ?", id).First(&company)
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&company)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@ -46,7 +47,7 @@ func GetCompanyByID(ctx context.Context, id types.ULID) (*Company, error) {
|
||||
// GetAllCompanies returns all companies
|
||||
func GetAllCompanies(ctx context.Context) ([]Company, error) {
|
||||
var companies []Company
|
||||
result := GetEngine(ctx).Find(&companies)
|
||||
result := db.GetEngine(ctx).Find(&companies)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -55,7 +56,7 @@ func GetAllCompanies(ctx context.Context) ([]Company, error) {
|
||||
|
||||
func GetCustomersByCompanyID(ctx context.Context, companyID int) ([]Customer, error) {
|
||||
var customers []Customer
|
||||
result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&customers)
|
||||
result := db.GetEngine(ctx).Where("company_id = ?", companyID).Find(&customers)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -68,7 +69,7 @@ func CreateCompany(ctx context.Context, create CompanyCreate) (*Company, error)
|
||||
Name: create.Name,
|
||||
}
|
||||
|
||||
result := GetEngine(ctx).Create(&company)
|
||||
result := db.GetEngine(ctx).Create(&company)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -96,6 +97,6 @@ func UpdateCompany(ctx context.Context, update CompanyUpdate) (*Company, error)
|
||||
|
||||
// DeleteCompany deletes a company by its ID
|
||||
func DeleteCompany(ctx context.Context, id types.ULID) error {
|
||||
result := GetEngine(ctx).Delete(&Company{}, id)
|
||||
result := db.GetEngine(ctx).Delete(&Company{}, id)
|
||||
return result.Error
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -39,7 +40,7 @@ type CustomerUpdate struct {
|
||||
// GetCustomerByID finds a customer by its ID
|
||||
func GetCustomerByID(ctx context.Context, id types.ULID) (*Customer, error) {
|
||||
var customer Customer
|
||||
result := GetEngine(ctx).Where("id = ?", id).First(&customer)
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&customer)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@ -52,7 +53,7 @@ func GetCustomerByID(ctx context.Context, id types.ULID) (*Customer, error) {
|
||||
// GetAllCustomers returns all customers
|
||||
func GetAllCustomers(ctx context.Context) ([]Customer, error) {
|
||||
var customers []Customer
|
||||
result := GetEngine(ctx).Find(&customers)
|
||||
result := db.GetEngine(ctx).Find(&customers)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -66,7 +67,7 @@ func CreateCustomer(ctx context.Context, create CustomerCreate) (*Customer, erro
|
||||
CompanyID: create.CompanyID,
|
||||
}
|
||||
|
||||
result := GetEngine(ctx).Create(&customer)
|
||||
result := db.GetEngine(ctx).Create(&customer)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -94,6 +95,6 @@ func UpdateCustomer(ctx context.Context, update CustomerUpdate) (*Customer, erro
|
||||
|
||||
// DeleteCustomer deletes a customer by its ID
|
||||
func DeleteCustomer(ctx context.Context, id types.ULID) error {
|
||||
result := GetEngine(ctx).Delete(&Customer{}, id)
|
||||
result := db.GetEngine(ctx).Delete(&Customer{}, id)
|
||||
return result.Error
|
||||
}
|
||||
|
@ -9,101 +9,33 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres" // For PostgreSQL
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/permissions"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Global variable for the DB connection
|
||||
var defaultDB *gorm.DB
|
||||
|
||||
// DatabaseConfig contains the configuration data for the database connection
|
||||
type DatabaseConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
DBName string
|
||||
SSLMode string
|
||||
MaxIdleConns int // Maximum number of idle connections
|
||||
MaxOpenConns int // Maximum number of open connections
|
||||
MaxLifetime time.Duration // Maximum lifetime of a connection
|
||||
LogLevel logger.LogLevel
|
||||
}
|
||||
|
||||
// DefaultDatabaseConfig returns a default configuration with sensible values
|
||||
func DefaultDatabaseConfig() DatabaseConfig {
|
||||
return DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "timetracker",
|
||||
Password: "password",
|
||||
DBName: "timetracker",
|
||||
SSLMode: "disable",
|
||||
MaxIdleConns: 10,
|
||||
MaxOpenConns: 100,
|
||||
MaxLifetime: time.Hour,
|
||||
LogLevel: logger.Info,
|
||||
}
|
||||
}
|
||||
|
||||
// InitDB initializes the database connection (once at startup)
|
||||
// with the provided configuration
|
||||
func InitDB(config DatabaseConfig) error {
|
||||
// Create DSN (Data Source Name)
|
||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
config.Host, config.Port, config.User, config.Password, config.DBName, config.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: config.LogLevel, // Log level
|
||||
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
|
||||
Colorful: true, // Enable color
|
||||
},
|
||||
)
|
||||
|
||||
// Establish database connection with custom logger
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error connecting to the database: %w", err)
|
||||
}
|
||||
|
||||
// Configure connection pool
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting database connection: %w", err)
|
||||
}
|
||||
|
||||
// Set connection pool parameters
|
||||
sqlDB.SetMaxIdleConns(config.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(config.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(config.MaxLifetime)
|
||||
|
||||
defaultDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateDB performs database migrations for all models
|
||||
func MigrateDB() error {
|
||||
if defaultDB == nil {
|
||||
gormDB := db.GetEngine(context.Background())
|
||||
if gormDB == nil {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
log.Println("Starting database migration...")
|
||||
|
||||
// Add all models that should be migrated here
|
||||
err := defaultDB.AutoMigrate(
|
||||
err := gormDB.AutoMigrate(
|
||||
&Company{},
|
||||
&User{},
|
||||
&Customer{},
|
||||
&Project{},
|
||||
&Activity{},
|
||||
&TimeEntry{},
|
||||
&permissions.Role{},
|
||||
&permissions.Policy{},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@ -114,34 +46,8 @@ func MigrateDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEngine returns the DB instance, possibly with context
|
||||
func GetEngine(ctx context.Context) *gorm.DB {
|
||||
if defaultDB == nil {
|
||||
panic("database not initialized")
|
||||
}
|
||||
// If a special transaction is in ctx, you could check it here
|
||||
return defaultDB.WithContext(ctx)
|
||||
}
|
||||
|
||||
// CloseDB closes the database connection
|
||||
func CloseDB() error {
|
||||
if defaultDB == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sqlDB, err := defaultDB.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting database connection: %w", err)
|
||||
}
|
||||
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
return fmt.Errorf("error closing database connection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetGormDB(dbConfig DatabaseConfig, dbName string) (*gorm.DB, error) {
|
||||
// GetGormDB is used for special cases like database creation
|
||||
func GetGormDB(dbConfig config.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)
|
||||
|
||||
@ -184,7 +90,7 @@ func UpdateModel(ctx context.Context, model any, updates any) error {
|
||||
updateMap := make(map[string]any)
|
||||
|
||||
// Iterate through all fields
|
||||
for i := 0; i < updateValue.NumField(); i++ {
|
||||
for i := range updateValue.NumField() {
|
||||
field := updateValue.Field(i)
|
||||
fieldType := updateType.Field(i)
|
||||
|
||||
@ -223,5 +129,14 @@ func UpdateModel(ctx context.Context, model any, updates any) error {
|
||||
return nil // Nothing to update
|
||||
}
|
||||
|
||||
return GetEngine(ctx).Model(model).Updates(updateMap).Error
|
||||
return db.GetEngine(ctx).Model(model).Updates(updateMap).Error
|
||||
}
|
||||
|
||||
// InitDB and CloseDB are forwarded to the db package for backward compatibility
|
||||
func InitDB(config config.DatabaseConfig) error {
|
||||
return db.InitDB(config)
|
||||
}
|
||||
|
||||
func CloseDB() error {
|
||||
return db.CloseDB()
|
||||
}
|
||||
|
@ -1,13 +1,4 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `env:"JWT_SECRET" default:""`
|
||||
TokenDuration time.Duration `env:"JWT_TOKEN_DURATION" default:"24h"`
|
||||
KeyGenerate bool `env:"JWT_KEY_GENERATE" default:"true"`
|
||||
KeyDir string `env:"JWT_KEY_DIR" default:"./keys"`
|
||||
PrivKeyFile string `env:"JWT_PRIV_KEY_FILE" default:"jwt.key"`
|
||||
PubKeyFile string `env:"JWT_PUB_KEY_FILE" default:"jwt.key.pub"`
|
||||
KeyBits int `env:"JWT_KEY_BITS" default:"2048"`
|
||||
}
|
||||
// This file is intentionally left empty.
|
||||
// The JWTConfig struct has been moved to the config package.
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -13,7 +14,7 @@ import (
|
||||
type Project struct {
|
||||
EntityBase
|
||||
Name string `gorm:"column:name;not null"`
|
||||
CustomerID *types.ULID `gorm:"column:customer_id;type:bytea;not null"`
|
||||
CustomerID *types.ULID `gorm:"column:customer_id;type:bytea;index"`
|
||||
|
||||
// Relationships (for Eager Loading)
|
||||
Customer *Customer `gorm:"foreignKey:CustomerID"`
|
||||
@ -32,9 +33,9 @@ type ProjectCreate struct {
|
||||
|
||||
// ProjectUpdate contains the updatable fields of a project
|
||||
type ProjectUpdate struct {
|
||||
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||
Name *string `gorm:"column:name"`
|
||||
CustomerID *types.ULID `gorm:"column:customer_id"`
|
||||
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||
Name *string `gorm:"column:name"`
|
||||
CustomerID types.Nullable[types.ULID] `gorm:"column:customer_id"`
|
||||
}
|
||||
|
||||
// Validate checks if the Create struct contains valid data
|
||||
@ -43,7 +44,7 @@ func (pc *ProjectCreate) Validate() error {
|
||||
return errors.New("project name cannot be empty")
|
||||
}
|
||||
// Check for valid CustomerID
|
||||
if pc.CustomerID.Compare(types.ULID{}) == 0 {
|
||||
if pc.CustomerID != nil && pc.CustomerID.Compare(types.ULID{}) == 0 {
|
||||
return errors.New("customerID cannot be empty")
|
||||
}
|
||||
return nil
|
||||
@ -60,7 +61,7 @@ func (pu *ProjectUpdate) Validate() error {
|
||||
// GetProjectByID finds a project by its ID
|
||||
func GetProjectByID(ctx context.Context, id types.ULID) (*Project, error) {
|
||||
var project Project
|
||||
result := GetEngine(ctx).Where("id = ?", id).First(&project)
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&project)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@ -73,7 +74,7 @@ func GetProjectByID(ctx context.Context, id types.ULID) (*Project, error) {
|
||||
// GetProjectWithCustomer loads a project with the associated customer information
|
||||
func GetProjectWithCustomer(ctx context.Context, id types.ULID) (*Project, error) {
|
||||
var project Project
|
||||
result := GetEngine(ctx).Preload("Customer").Where("id = ?", id).First(&project)
|
||||
result := db.GetEngine(ctx).Preload("Customer").Where("id = ?", id).First(&project)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@ -86,7 +87,7 @@ func GetProjectWithCustomer(ctx context.Context, id types.ULID) (*Project, error
|
||||
// GetAllProjects returns all projects
|
||||
func GetAllProjects(ctx context.Context) ([]Project, error) {
|
||||
var projects []Project
|
||||
result := GetEngine(ctx).Find(&projects)
|
||||
result := db.GetEngine(ctx).Find(&projects)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -96,7 +97,7 @@ func GetAllProjects(ctx context.Context) ([]Project, error) {
|
||||
// GetAllProjectsWithCustomers returns all projects with customer information
|
||||
func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) {
|
||||
var projects []Project
|
||||
result := GetEngine(ctx).Preload("Customer").Find(&projects)
|
||||
result := db.GetEngine(ctx).Preload("Customer").Find(&projects)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -106,7 +107,7 @@ func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) {
|
||||
// GetProjectsByCustomerID returns all projects of a specific customer
|
||||
func GetProjectsByCustomerID(ctx context.Context, customerId types.ULID) ([]Project, error) {
|
||||
var projects []Project
|
||||
result := GetEngine(ctx).Where("customer_id = ?", customerId.ULID).Find(&projects)
|
||||
result := db.GetEngine(ctx).Where("customer_id = ?", customerId.ULID).Find(&projects)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -121,7 +122,7 @@ func CreateProject(ctx context.Context, create ProjectCreate) (*Project, error)
|
||||
}
|
||||
|
||||
// Check if the customer exists
|
||||
if create.CustomerID == nil {
|
||||
if create.CustomerID != nil {
|
||||
customer, err := GetCustomerByID(ctx, *create.CustomerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error checking the customer: %w", err)
|
||||
@ -136,7 +137,7 @@ func CreateProject(ctx context.Context, create ProjectCreate) (*Project, error)
|
||||
CustomerID: create.CustomerID,
|
||||
}
|
||||
|
||||
result := GetEngine(ctx).Create(&project)
|
||||
result := db.GetEngine(ctx).Create(&project)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("error creating the project: %w", result.Error)
|
||||
}
|
||||
@ -159,13 +160,18 @@ func UpdateProject(ctx context.Context, update ProjectUpdate) (*Project, error)
|
||||
}
|
||||
|
||||
// If CustomerID is updated, check if the customer exists
|
||||
if update.CustomerID != nil {
|
||||
customer, err := GetCustomerByID(ctx, *update.CustomerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error checking the customer: %w", err)
|
||||
}
|
||||
if customer == nil {
|
||||
return nil, errors.New("the specified customer does not exist")
|
||||
if update.CustomerID.Valid {
|
||||
if update.CustomerID.Value != nil {
|
||||
customer, err := GetCustomerByID(ctx, *update.CustomerID.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error checking the customer: %w", err)
|
||||
}
|
||||
if customer == nil {
|
||||
return nil, errors.New("the specified customer does not exist")
|
||||
}
|
||||
} else {
|
||||
// If CustomerID is nil, set it to nil in the project
|
||||
project.CustomerID = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,7 +187,7 @@ func UpdateProject(ctx context.Context, update ProjectUpdate) (*Project, error)
|
||||
// DeleteProject deletes a project by its ID
|
||||
func DeleteProject(ctx context.Context, id types.ULID) error {
|
||||
// Here you could check if dependent entities exist
|
||||
result := GetEngine(ctx).Delete(&Project{}, id)
|
||||
result := db.GetEngine(ctx).Delete(&Project{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("error deleting the project: %w", result.Error)
|
||||
}
|
||||
@ -198,7 +204,7 @@ func CreateProjectWithTransaction(ctx context.Context, create ProjectCreate) (*P
|
||||
var project *Project
|
||||
|
||||
// Start transaction
|
||||
err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Customer check within the transaction
|
||||
var customer Customer
|
||||
if err := tx.Where("id = ?", create.CustomerID).First(&customer).Error; err != nil {
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -105,7 +106,7 @@ func (tu *TimeEntryUpdate) Validate() error {
|
||||
// GetTimeEntryByID finds a time entry by its ID
|
||||
func GetTimeEntryByID(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
||||
var timeEntry TimeEntry
|
||||
result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@ -118,7 +119,7 @@ func GetTimeEntryByID(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
||||
// GetTimeEntryWithRelations loads a time entry with all associated data
|
||||
func GetTimeEntryWithRelations(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
||||
var timeEntry TimeEntry
|
||||
result := GetEngine(ctx).
|
||||
result := db.GetEngine(ctx).
|
||||
Preload("User").
|
||||
Preload("Project").
|
||||
Preload("Project.Customer"). // Nested relationship
|
||||
@ -138,7 +139,7 @@ func GetTimeEntryWithRelations(ctx context.Context, id types.ULID) (*TimeEntry,
|
||||
// GetAllTimeEntries returns all time entries
|
||||
func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) {
|
||||
var timeEntries []TimeEntry
|
||||
result := GetEngine(ctx).Find(&timeEntries)
|
||||
result := db.GetEngine(ctx).Find(&timeEntries)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -148,7 +149,7 @@ func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) {
|
||||
// GetTimeEntriesByUserID returns all time entries of a user
|
||||
func GetTimeEntriesByUserID(ctx context.Context, userID types.ULID) ([]TimeEntry, error) {
|
||||
var timeEntries []TimeEntry
|
||||
result := GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries)
|
||||
result := db.GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -158,7 +159,7 @@ func GetTimeEntriesByUserID(ctx context.Context, userID types.ULID) ([]TimeEntry
|
||||
// GetTimeEntriesByProjectID returns all time entries of a project
|
||||
func GetTimeEntriesByProjectID(ctx context.Context, projectID types.ULID) ([]TimeEntry, error) {
|
||||
var timeEntries []TimeEntry
|
||||
result := GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries)
|
||||
result := db.GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -169,7 +170,7 @@ func GetTimeEntriesByProjectID(ctx context.Context, projectID types.ULID) ([]Tim
|
||||
func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) {
|
||||
var timeEntries []TimeEntry
|
||||
// Search for overlaps in the time range
|
||||
result := GetEngine(ctx).
|
||||
result := db.GetEngine(ctx).
|
||||
Where("(start BETWEEN ? AND ?) OR (end BETWEEN ? AND ?)",
|
||||
start, end, start, end).
|
||||
Find(&timeEntries)
|
||||
@ -189,7 +190,7 @@ func SumBillableHoursByProject(ctx context.Context, projectID types.ULID) (float
|
||||
var result Result
|
||||
|
||||
// SQL calculation of weighted hours
|
||||
err := GetEngine(ctx).Raw(`
|
||||
err := db.GetEngine(ctx).Raw(`
|
||||
SELECT SUM(
|
||||
EXTRACT(EPOCH FROM (end - start)) / 3600 * (billable / 100.0)
|
||||
) as total_hours
|
||||
@ -214,7 +215,7 @@ func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, e
|
||||
// Start a transaction
|
||||
var timeEntry *TimeEntry
|
||||
|
||||
err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Check references
|
||||
if err := validateReferences(tx, create.UserID, create.ProjectID, create.ActivityID); err != nil {
|
||||
return err
|
||||
@ -295,7 +296,7 @@ func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, e
|
||||
}
|
||||
|
||||
// Start a transaction for the update
|
||||
err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err = db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Check references if they are updated
|
||||
if update.UserID != nil || update.ProjectID != nil || update.ActivityID != nil {
|
||||
// Use current values if not updated
|
||||
@ -352,7 +353,7 @@ func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, e
|
||||
|
||||
// DeleteTimeEntry deletes a time entry by its ID
|
||||
func DeleteTimeEntry(ctx context.Context, id types.ULID) error {
|
||||
result := GetEngine(ctx).Delete(&TimeEntry{}, id)
|
||||
result := db.GetEngine(ctx).Delete(&TimeEntry{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("error deleting the time entry: %w", result.Error)
|
||||
}
|
||||
|
@ -11,28 +11,12 @@ import (
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Argon2 Parameters
|
||||
const (
|
||||
// Recommended values for Argon2id
|
||||
ArgonTime = 1
|
||||
ArgonMemory = 64 * 1024 // 64MB
|
||||
ArgonThreads = 4
|
||||
ArgonKeyLen = 32
|
||||
SaltLength = 16
|
||||
)
|
||||
|
||||
// Role Constants
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleUser = "user"
|
||||
RoleViewer = "viewer"
|
||||
)
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
EntityBase
|
||||
@ -42,6 +26,7 @@ type User struct {
|
||||
Role string `gorm:"column:role;not null;default:'user'"`
|
||||
CompanyID *types.ULID `gorm:"column:company_id;type:bytea;index"`
|
||||
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
||||
Companies []string `gorm:"type:text[]"`
|
||||
|
||||
// Relationship for Eager Loading
|
||||
Company *Company `gorm:"foreignKey:CompanyID"`
|
||||
@ -202,7 +187,7 @@ func (uc *UserCreate) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if uc.CompanyID.Compare(types.ULID{}) == 0 {
|
||||
if uc.CompanyID != nil && uc.CompanyID.Compare(types.ULID{}) == 0 {
|
||||
return errors.New("companyID cannot be empty")
|
||||
}
|
||||
|
||||
@ -290,7 +275,7 @@ func (uu *UserUpdate) Validate() error {
|
||||
// GetUserByID finds a user by their ID
|
||||
func GetUserByID(ctx context.Context, id types.ULID) (*User, error) {
|
||||
var user User
|
||||
result := GetEngine(ctx).Where("id = ?", id).First(&user)
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&user)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@ -303,7 +288,7 @@ func GetUserByID(ctx context.Context, id types.ULID) (*User, error) {
|
||||
// GetUserByEmail finds a user by their email
|
||||
func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var user User
|
||||
result := GetEngine(ctx).Where("email = ?", email).First(&user)
|
||||
result := db.GetEngine(ctx).Where("email = ?", email).First(&user)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@ -316,7 +301,7 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
// GetUserWithCompany loads a user with their company
|
||||
func GetUserWithCompany(ctx context.Context, id types.ULID) (*User, error) {
|
||||
var user User
|
||||
result := GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user)
|
||||
result := db.GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
@ -329,7 +314,7 @@ func GetUserWithCompany(ctx context.Context, id types.ULID) (*User, error) {
|
||||
// GetAllUsers returns all users
|
||||
func GetAllUsers(ctx context.Context) ([]User, error) {
|
||||
var users []User
|
||||
result := GetEngine(ctx).Find(&users)
|
||||
result := db.GetEngine(ctx).Find(&users)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -351,7 +336,7 @@ func GetUsersByCompanyID(ctx context.Context, companyID types.ULID) ([]User, err
|
||||
var users []User
|
||||
// Apply the dynamic company condition
|
||||
condition := getCompanyCondition(&companyID)
|
||||
result := GetEngine(ctx).Scopes(condition).Find(&users)
|
||||
result := db.GetEngine(ctx).Scopes(condition).Find(&users)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -368,7 +353,7 @@ func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
|
||||
// Start a transaction
|
||||
var user *User
|
||||
|
||||
err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Check if email already exists
|
||||
var count int64
|
||||
if err := tx.Model(&User{}).Where("email = ?", create.Email).Count(&count).Error; err != nil {
|
||||
@ -378,13 +363,15 @@ func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
|
||||
return errors.New("email is already in use")
|
||||
}
|
||||
|
||||
// Check if company exists
|
||||
var companyCount int64
|
||||
if err := tx.Model(&Company{}).Where("id = ?", create.CompanyID).Count(&companyCount).Error; err != nil {
|
||||
return fmt.Errorf("error checking company: %w", err)
|
||||
}
|
||||
if companyCount == 0 {
|
||||
return errors.New("the specified company does not exist")
|
||||
if create.CompanyID != nil {
|
||||
// Check if company exists
|
||||
var companyCount int64
|
||||
if err := tx.Model(&Company{}).Where("id = ?", create.CompanyID).Count(&companyCount).Error; err != nil {
|
||||
return fmt.Errorf("error checking company: %w", err)
|
||||
}
|
||||
if companyCount == 0 {
|
||||
return errors.New("the specified company does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password with unique salt
|
||||
@ -435,7 +422,7 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
|
||||
}
|
||||
|
||||
// Start a transaction for the update
|
||||
err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err = db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// If email is updated, check if it's already in use
|
||||
if update.Email != nil && *update.Email != user.Email {
|
||||
var count int64
|
||||
@ -492,7 +479,6 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
|
||||
} else {
|
||||
updates["company_id"] = *update.CompanyID.Value
|
||||
}
|
||||
|
||||
}
|
||||
if update.HourlyRate != nil {
|
||||
updates["hourly_rate"] = *update.HourlyRate
|
||||
@ -521,7 +507,7 @@ func DeleteUser(ctx context.Context, id types.ULID) error {
|
||||
// Here one could check if dependent entities exist
|
||||
// e.g., don't delete if time entries still exist
|
||||
|
||||
result := GetEngine(ctx).Delete(&User{}, id)
|
||||
result := db.GetEngine(ctx).Delete(&User{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("error deleting user: %w", result.Error)
|
||||
}
|
||||
@ -551,3 +537,20 @@ func AuthenticateUser(ctx context.Context, email, password string) (*User, error
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Argon2 Parameters
|
||||
const (
|
||||
// Recommended values for Argon2id
|
||||
ArgonTime = 1
|
||||
ArgonMemory = 64 * 1024 // 64MB
|
||||
ArgonThreads = 4
|
||||
ArgonKeyLen = 32
|
||||
SaltLength = 16
|
||||
)
|
||||
|
||||
// Role Constants
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleUser = "user"
|
||||
RoleViewer = "viewer"
|
||||
)
|
||||
|
35
backend/internal/permissions/evaluator.go
Normal file
35
backend/internal/permissions/evaluator.go
Normal file
@ -0,0 +1,35 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
func (u *User) EffectivePermissions(ctx context.Context, scope string) (Permission, error) {
|
||||
if u.ActiveRole == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Load the role and its associated policies using the helper function.
|
||||
role, err := LoadRoleWithPolicies(ctx, u.ActiveRole.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var perm Permission
|
||||
for _, policy := range role.Policies {
|
||||
for pat, p := range policy.Scopes {
|
||||
if MatchScope(pat, scope) {
|
||||
perm |= p
|
||||
}
|
||||
}
|
||||
}
|
||||
return perm, nil
|
||||
}
|
||||
|
||||
func (u *User) HasPermission(ctx context.Context, scope string, requiredPerm Permission) (bool, error) {
|
||||
effective, err := u.EffectivePermissions(ctx, scope)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return (effective & requiredPerm) == requiredPerm, nil
|
||||
}
|
23
backend/internal/permissions/helpers.go
Normal file
23
backend/internal/permissions/helpers.go
Normal file
@ -0,0 +1,23 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LoadRoleWithPolicies loads a role with its associated policies from the database.
|
||||
func LoadRoleWithPolicies(ctx context.Context, roleID ulid.ULID) (*Role, error) {
|
||||
var role Role
|
||||
err := db.GetEngine(ctx).Preload("Policies").First(&role, "id = ?", roleID).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("role with ID %s not found", roleID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to load role: %w", err)
|
||||
}
|
||||
return &role, nil
|
||||
}
|
11
backend/internal/permissions/matching.go
Normal file
11
backend/internal/permissions/matching.go
Normal file
@ -0,0 +1,11 @@
|
||||
package permissions
|
||||
|
||||
import "strings"
|
||||
|
||||
func MatchScope(pattern, scope string) bool {
|
||||
if strings.HasSuffix(pattern, "/*") {
|
||||
prefix := strings.TrimSuffix(pattern, "/*")
|
||||
return strings.HasPrefix(scope, prefix)
|
||||
}
|
||||
return pattern == scope
|
||||
}
|
13
backend/internal/permissions/permissions.go
Normal file
13
backend/internal/permissions/permissions.go
Normal file
@ -0,0 +1,13 @@
|
||||
package permissions
|
||||
|
||||
type Permission uint64
|
||||
|
||||
const (
|
||||
PermRead Permission = 1 << iota // 1
|
||||
PermWrite // 2
|
||||
PermCreate // 4
|
||||
PermList // 8
|
||||
PermDelete // 16
|
||||
PermModerate // 32
|
||||
PermSuperadmin // 64
|
||||
)
|
40
backend/internal/permissions/policy.go
Normal file
40
backend/internal/permissions/policy.go
Normal file
@ -0,0 +1,40 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
ID ulid.ULID `gorm:"primaryKey;type:bytea"`
|
||||
Name string `gorm:"not null"`
|
||||
RoleID ulid.ULID `gorm:"type:bytea"` //Fremdschlüssel
|
||||
Scopes Scopes `gorm:"type:jsonb;not null"` // JSONB-Spalte
|
||||
}
|
||||
|
||||
// Scopes type to handle JSON marshalling
|
||||
type Scopes map[string]Permission
|
||||
|
||||
// Scan scan value into Jsonb, implements sql.Scanner interface
|
||||
func (j *Scopes) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value))
|
||||
}
|
||||
|
||||
var scopes map[string]Permission
|
||||
if err := json.Unmarshal(bytes, &scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
*j = scopes
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value return json value, implement driver.Valuer interface
|
||||
func (j Scopes) Value() (driver.Value, error) {
|
||||
return json.Marshal(j)
|
||||
}
|
11
backend/internal/permissions/role.go
Normal file
11
backend/internal/permissions/role.go
Normal file
@ -0,0 +1,11 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
type Role struct {
|
||||
ID ulid.ULID `gorm:"primaryKey;type:bytea"`
|
||||
Name string `gorm:"unique;not null"`
|
||||
Policies []Policy `gorm:"foreignKey:RoleID"`
|
||||
}
|
10
backend/internal/permissions/user.go
Normal file
10
backend/internal/permissions/user.go
Normal file
@ -0,0 +1,10 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ActiveRole *Role `gorm:"foreignKey:UserID"` // Beziehung zur aktiven Rolle
|
||||
UserID ulid.ULID `gorm:"type:bytea"` //Fremdschlüssel
|
||||
}
|
BIN
backend/migrate
Executable file
BIN
backend/migrate
Executable file
Binary file not shown.
168
backend/postman/activity.postman_collection.json
Normal file
168
backend/postman/activity.postman_collection.json
Normal file
@ -0,0 +1,168 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "Activity API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/activities",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/activities",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"activities"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/activities/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/activities/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"activities",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/activities",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"billingRate\": 0\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/activities",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"activities"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/activities/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"billingRate\": 0\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/activities/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"activities",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/activities/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/activities/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"activities",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
168
backend/postman/company.postman_collection.json
Normal file
168
backend/postman/company.postman_collection.json
Normal file
@ -0,0 +1,168 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "Company API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/companies",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/companies",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"companies"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/companies/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/companies/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"companies",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/companies",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/companies",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"companies"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/companies/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/companies/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"companies",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/companies/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/companies/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"companies",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
200
backend/postman/customer.postman_collection.json
Normal file
200
backend/postman/customer.postman_collection.json
Normal file
@ -0,0 +1,200 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "Customer API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/customers",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/customers/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/customers/company/:companyId",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers/company/:companyId",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers",
|
||||
"company",
|
||||
":companyId"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "companyId",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/customers",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"companyId\": \"\",\n\t\"ownerUserID\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/customers/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"companyId\": \"\",\n\t\"ownerUserID\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/customers/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
225
backend/postman/project.postman_collection.json
Normal file
225
backend/postman/project.postman_collection.json
Normal file
@ -0,0 +1,225 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "Project API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/projects",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/projects/with-customers",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects/with-customers",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects",
|
||||
"with-customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/projects/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/projects/customer/:customerId",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects/customer/:customerId",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects",
|
||||
"customer",
|
||||
":customerId"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "customerId",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/projects",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"customerId\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/projects/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"customerId\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/projects/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
292
backend/postman/timeentry.postman_collection.json
Normal file
292
backend/postman/timeentry.postman_collection.json
Normal file
@ -0,0 +1,292 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "TimeEntry API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/time-entries",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/time-entries/me",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/me",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
"me"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/time-entries/range",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/range?start=2023-01-01T00:00:00Z&end=2023-01-02T00:00:00Z",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
"range"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "start",
|
||||
"value": "2023-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"key": "end",
|
||||
"value": "2023-01-02T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/time-entries/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/time-entries/user/:userId",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/user/:userId",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
"user",
|
||||
":userId"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "userId",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/time-entries/project/:projectId",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/project/:projectId",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
"project",
|
||||
":projectId"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "projectId",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/time-entries",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userID\": \"\",\n \"projectID\": \"\",\n \"activityID\": \"\",\n \"start\": \"2023-01-01T00:00:00Z\",\n \"end\": \"2023-01-01T01:00:00Z\",\n \"description\": \"\",\n \"billable\": true\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/time-entries/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userID\": \"\",\n \"projectID\": \"\",\n \"activityID\": \"\",\n \"start\": \"2023-01-01T00:00:00Z\",\n \"end\": \"2023-01-01T01:00:00Z\",\n \"description\": \"\",\n \"billable\": true\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/time-entries/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
241
backend/postman/user.postman_collection.json
Normal file
241
backend/postman/user.postman_collection.json
Normal file
@ -0,0 +1,241 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "User API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Auth",
|
||||
"item": [
|
||||
{
|
||||
"name": "POST /api/auth/login",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"email\": \"\",\n\t\"password\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/auth/login",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"auth",
|
||||
"login"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/auth/register",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"email\": \"\",\n\t\"password\": \"\",\n\t\"role\": \"user\",\n\t\"companyID\": \"\",\n\t\"hourlyRate\": 0\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/auth/register",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"auth",
|
||||
"register"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/auth/me",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/auth/me",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"auth",
|
||||
"me"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Users",
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/users",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/users",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/users/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/users/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"users",
|
||||
":id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/users",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"email\": \"\",\n\t\"password\": \"\",\n\t\"role\": \"user\",\n\t\"companyID\": \"\",\n\t\"hourlyRate\": 0\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/users",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/users/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"email\": \"\",\n\t\"password\": \"\",\n\t\"role\": \"user\",\n\t\"companyID\": \"\",\n\t\"hourlyRate\": 0\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/users/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"users",
|
||||
":id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/users/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/users/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"users",
|
||||
":id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
250
docu/permissions_plan.md
Normal file
250
docu/permissions_plan.md
Normal file
@ -0,0 +1,250 @@
|
||||
# Berechtigungssystem Plan
|
||||
|
||||
Dieser Plan beschreibt die Implementierung eines scope-basierten Berechtigungssystems für das TimeTracker-Projekt.
|
||||
|
||||
## Grundkonzept
|
||||
|
||||
- Ein **Benutzer** kann eine **Rolle** annehmen, aber immer nur eine ist aktiv.
|
||||
- Eine **Rolle** besteht aus mehreren **Policies**.
|
||||
- Eine **Policy** hat einen Namen und eine Map, die **Scopes** (z. B. `items/books`) einem **Berechtigungsschlüssel** (Bitflag) zuordnet.
|
||||
- Berechtigungsschlüssel sind Bitflags, die Permissions wie `read`, `write`, `create`, `list`, `delete`, `moderate`, `superadmin` usw. repräsentieren.
|
||||
- Scopes können **Wildcards** enthalten, z. B. `items/*`, das auf `items/books` vererbt wird.
|
||||
- Ziel: Berechtigungen sowohl im Go-Backend (für API-Sicherheit) als auch im TypeScript-Frontend (für UI-Anpassung) evaluieren.
|
||||
|
||||
## Implementierung im Go-Backend
|
||||
|
||||
### 1. Ordnerstruktur
|
||||
|
||||
- Neuer Ordner: `backend/internal/permissions`
|
||||
- Dateien:
|
||||
- `permissions.go`: `Permission`-Konstanten (Bitflags).
|
||||
- `policy.go`: `Policy`-Struktur.
|
||||
- `role.go`: `Role`-Struktur.
|
||||
- `user.go`: Erweiterung der `User`-Struktur.
|
||||
- `matching.go`: `matchScope`-Funktion.
|
||||
- `evaluator.go`: `EffectivePermissions`- und `HasPermission`-Funktionen.
|
||||
|
||||
### 2. Go-Strukturen
|
||||
|
||||
- `permissions.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
type Permission uint64
|
||||
|
||||
const (
|
||||
PermRead Permission = 1 << iota // 1
|
||||
PermWrite // 2
|
||||
PermCreate // 4
|
||||
PermList // 8
|
||||
PermDelete // 16
|
||||
PermModerate // 32
|
||||
PermSuperadmin // 64
|
||||
)
|
||||
```
|
||||
|
||||
- `policy.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
type Policy struct {
|
||||
Name string
|
||||
Scopes map[string]Permission
|
||||
}
|
||||
```
|
||||
|
||||
- `role.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
type Role struct {
|
||||
Name string
|
||||
Policies []Policy
|
||||
}
|
||||
```
|
||||
|
||||
- `user.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
import "github.com/your-org/your-project/backend/internal/models" // Pfad anpassen
|
||||
|
||||
type User struct {
|
||||
models.User // Einbettung
|
||||
ActiveRole *Role
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Funktionen
|
||||
|
||||
- `matching.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
import "strings"
|
||||
|
||||
func MatchScope(pattern, scope string) bool {
|
||||
if strings.HasSuffix(pattern, "/*") {
|
||||
prefix := strings.TrimSuffix(pattern, "/*")
|
||||
return strings.HasPrefix(scope, prefix)
|
||||
}
|
||||
return pattern == scope
|
||||
}
|
||||
```
|
||||
|
||||
- `evaluator.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
func (u *User) EffectivePermissions(scope string) Permission {
|
||||
if u.ActiveRole == nil {
|
||||
return 0
|
||||
}
|
||||
var perm Permission
|
||||
for _, policy := range u.ActiveRole.Policies {
|
||||
for pat, p := range policy.Scopes {
|
||||
if MatchScope(pat, scope) {
|
||||
perm |= p
|
||||
}
|
||||
}
|
||||
}
|
||||
return perm
|
||||
}
|
||||
|
||||
func (u *User) HasPermission(scope string, requiredPerm Permission) bool {
|
||||
effective := u.EffectivePermissions(scope)
|
||||
return (effective & requiredPerm) == requiredPerm
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Integration in die API-Handler
|
||||
|
||||
- Anpassung der `jwt_auth.go` Middleware.
|
||||
- Verwendung von `HasPermission` in den API-Handlern.
|
||||
|
||||
## Persistierung (Datenbank)
|
||||
|
||||
### 1. Datenbankmodell
|
||||
|
||||
- Zwei neue Tabellen: `roles` und `policies`.
|
||||
- `roles`:
|
||||
- `id` (ULID, Primärschlüssel)
|
||||
- `name` (VARCHAR, eindeutig)
|
||||
- `policies`:
|
||||
- `id` (ULID, Primärschlüssel)
|
||||
- `name` (VARCHAR, eindeutig)
|
||||
- `role_id` (ULID, Fremdschlüssel, der auf `roles.id` verweist)
|
||||
- `scopes` (JSONB oder TEXT, speichert die `map[string]Permission` als JSON)
|
||||
- Beziehung: 1:n zwischen `roles` und `policies`.
|
||||
|
||||
### 2. Go-Strukturen (Anpassungen)
|
||||
|
||||
- `role.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"github.com/your-org/your-project/backend/internal/types" // Pfad anpassen
|
||||
)
|
||||
|
||||
type Role struct {
|
||||
ID types.ULID `gorm:"primaryKey;type:bytea"`
|
||||
Name string `gorm:"unique;not null"`
|
||||
Policies []Policy `gorm:"foreignKey:RoleID"`
|
||||
}
|
||||
```
|
||||
|
||||
- `policy.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/your-org/your-project/backend/internal/types" // Pfad anpassen
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
ID types.ULID `gorm:"primaryKey;type:bytea"`
|
||||
Name string `gorm:"not null"`
|
||||
RoleID types.ULID `gorm:"type:bytea"` //Fremdschlüssel
|
||||
Scopes Scopes `gorm:"type:jsonb;not null"` // JSONB-Spalte
|
||||
}
|
||||
|
||||
//Scopes type to handle JSON marshalling
|
||||
type Scopes map[string]Permission
|
||||
|
||||
// Scan scan value into Jsonb, implements sql.Scanner interface
|
||||
func (j *Scopes) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value))
|
||||
}
|
||||
|
||||
var scopes map[string]Permission
|
||||
if err := json.Unmarshal(bytes, &scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
*j = scopes
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value return json value, implement driver.Valuer interface
|
||||
func (j Scopes) Value() (driver.Value, error) {
|
||||
return json.Marshal(j)
|
||||
}
|
||||
```
|
||||
### 3. Migration
|
||||
|
||||
- Verwendung des vorhandenen Migrations-Frameworks (`backend/cmd/migrate/main.go`).
|
||||
|
||||
### 4. Seed-Daten
|
||||
|
||||
- Optionale Seed-Daten (`backend/cmd/seed/main.go`).
|
||||
|
||||
### 5. Anpassung der Funktionen
|
||||
|
||||
- Anpassung von `EffectivePermissions` und `HasPermission` in `evaluator.go` für Datenbankzugriff.
|
||||
|
||||
## Mermaid Diagramm
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Benutzer
|
||||
U[User] --> AR(ActiveRole)
|
||||
end
|
||||
subgraph Rolle
|
||||
AR --> R(Role)
|
||||
R --> P1(Policy 1)
|
||||
R --> P2(Policy 2)
|
||||
R --> Pn(Policy n)
|
||||
end
|
||||
subgraph Policy
|
||||
P1 --> S1(Scope 1: Permissions)
|
||||
P1 --> S2(Scope 2: Permissions)
|
||||
P2 --> S3(Scope 3: Permissions)
|
||||
Pn --> Sm(Scope m: Permissions)
|
||||
end
|
||||
|
||||
S1 -- Permissions --> PR(PermRead)
|
||||
S1 -- Permissions --> PW(PermWrite)
|
||||
S2 -- Permissions --> PL(PermList)
|
||||
Sm -- Permissions --> PD(PermDelete)
|
||||
|
||||
style U fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style R fill:#ccf,stroke:#333,stroke-width:2px
|
||||
style P1,P2,Pn fill:#ddf,stroke:#333,stroke-width:2px
|
||||
style S1,S2,S3,Sm fill:#eef,stroke:#333,stroke-width:1px
|
||||
style PR,PW,PL,PD fill:#ff9,stroke:#333,stroke-width:1px
|
113
flake.nix
Normal file
113
flake.nix
Normal file
@ -0,0 +1,113 @@
|
||||
{
|
||||
description = "Development environment for Go and Next.js (TypeScript)";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config = {
|
||||
allowUnfree = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Go development tools
|
||||
goPackages = with pkgs; [
|
||||
go
|
||||
gopls
|
||||
golangci-lint
|
||||
delve
|
||||
go-outline
|
||||
gotools
|
||||
go-mockgen
|
||||
gomodifytags
|
||||
impl
|
||||
gotests
|
||||
];
|
||||
|
||||
# TypeScript/Next.js development tools
|
||||
nodePackages = with pkgs; [
|
||||
nodejs_20
|
||||
nodePackages.typescript
|
||||
nodePackages.typescript-language-server
|
||||
nodePackages.yarn
|
||||
nodePackages.pnpm
|
||||
nodePackages.npm
|
||||
nodePackages.prettier
|
||||
nodePackages.eslint
|
||||
nodePackages.next
|
||||
];
|
||||
|
||||
# General development tools
|
||||
commonPackages = with pkgs; [
|
||||
git
|
||||
gh
|
||||
nixpkgs-fmt
|
||||
pre-commit
|
||||
ripgrep
|
||||
jq
|
||||
curl
|
||||
coreutils
|
||||
gnumake
|
||||
];
|
||||
|
||||
# VSCode with extensions
|
||||
vscodeWithExtensions = pkgs.vscode-with-extensions.override {
|
||||
vscodeExtensions = with pkgs.vscode-extensions; [
|
||||
golang.go # Go support
|
||||
esbenp.prettier-vscode # Prettier
|
||||
dbaeumer.vscode-eslint # ESLint
|
||||
ms-vscode.vscode-typescript-tslint-plugin # TypeScript
|
||||
bradlc.vscode-tailwindcss # Tailwind CSS support
|
||||
jnoortheen.nix-ide # Nix support
|
||||
] ++ pkgs.vscode-utils.extensionsFromVscodeMarketplace [
|
||||
{
|
||||
name = "nextjs";
|
||||
publisher = "pulkitgangwar";
|
||||
version = "1.0.6";
|
||||
sha256 = "sha256-L6ZgqNkM0qzSiTKiGfgQB9m3U0HmwLA3NZ9nrslQjeg=";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = goPackages ++ nodePackages ++ commonPackages ++ [ vscodeWithExtensions ];
|
||||
|
||||
shellHook = ''
|
||||
echo "🚀 Welcome to the Go and Next.js (TypeScript) development environment!"
|
||||
echo "📦 Available tools:"
|
||||
echo " Go: $(go version)"
|
||||
echo " Node: $(node --version)"
|
||||
echo " TypeScript: $(tsc --version)"
|
||||
echo " Next.js: $(npx next --version)"
|
||||
echo ""
|
||||
echo "🔧 Use 'code .' to open VSCode with the appropriate extensions"
|
||||
echo "🔄 Run 'nix flake update' to update dependencies"
|
||||
'';
|
||||
|
||||
# Environment variables
|
||||
GOROOT = "${pkgs.go}/share/go";
|
||||
GOPATH = "$(pwd)/.go";
|
||||
GO111MODULE = "on";
|
||||
|
||||
# NodeJS setup
|
||||
NODE_OPTIONS = "--max-old-space-size=4096";
|
||||
};
|
||||
|
||||
# Optional: Add custom packages if needed
|
||||
packages = {
|
||||
# Example of a custom package or script if needed
|
||||
# my-tool = ...
|
||||
};
|
||||
|
||||
# Default package if someone runs `nix build`
|
||||
defaultPackage = self.devShells.${system}.default;
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user