Compare commits
No commits in common. "main" and "ja/feat/init" have entirely different histories.
main
...
ja/feat/in
@ -1,19 +0,0 @@
|
|||||||
name: Gitea Actions Demo
|
|
||||||
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
|
|
||||||
on: [push]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
Explore-Gitea-Actions:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
|
|
||||||
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
|
|
||||||
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
|
|
||||||
- name: Check out repository code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
|
|
||||||
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
|
|
||||||
- name: List files in the repository
|
|
||||||
run: |
|
|
||||||
ls ${{ gitea.workspace }}
|
|
||||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
|
@ -7,8 +7,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/config"
|
|
||||||
"github.com/timetracker/backend/internal/db"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -17,15 +15,15 @@ func main() {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Get database configuration with sensible defaults
|
// Get database configuration with sensible defaults
|
||||||
dbConfig := config.DefaultDatabaseConfig()
|
dbConfig := models.DefaultDatabaseConfig()
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
fmt.Println("Connecting to database...")
|
fmt.Println("Connecting to database...")
|
||||||
if err := db.InitDB(dbConfig); err != nil {
|
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() {
|
||||||
if err := db.CloseDB(); err != nil {
|
if err := models.CloseDB(); err != nil {
|
||||||
log.Printf("Error closing database connection: %v", err)
|
log.Printf("Error closing database connection: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@ -36,7 +34,7 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Get the database engine
|
// Get the database engine
|
||||||
db := db.GetEngine(ctx)
|
db := models.GetEngine(ctx)
|
||||||
|
|
||||||
// Test database connection with a simple query
|
// Test database connection with a simple query
|
||||||
var result int
|
var result int
|
||||||
|
@ -6,8 +6,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/config"
|
|
||||||
"github.com/timetracker/backend/internal/db"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
@ -31,7 +29,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get database configuration with sensible defaults
|
// Get database configuration with sensible defaults
|
||||||
dbConfig := config.DefaultDatabaseConfig()
|
dbConfig := models.DefaultDatabaseConfig()
|
||||||
|
|
||||||
// Override with environment variables if provided
|
// Override with environment variables if provided
|
||||||
if host := os.Getenv("DB_HOST"); host != "" {
|
if host := os.Getenv("DB_HOST"); host != "" {
|
||||||
@ -64,7 +62,7 @@ func main() {
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
gormDB, err := db.GetGormDB(dbConfig, "postgres")
|
gormDB, err := models.GetGormDB(dbConfig, "postgres")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error getting gorm DB: %v", err)
|
log.Fatalf("Error getting gorm DB: %v", err)
|
||||||
}
|
}
|
||||||
@ -91,11 +89,11 @@ func main() {
|
|||||||
fmt.Printf("✓ Database %s created successfully\n", dbConfig.DBName)
|
fmt.Printf("✓ Database %s created successfully\n", dbConfig.DBName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = db.InitDB(dbConfig); err != nil {
|
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() {
|
||||||
if err := db.CloseDB(); err != nil {
|
if err := models.CloseDB(); err != nil {
|
||||||
log.Printf("Error closing database connection: %v", err)
|
log.Printf("Error closing database connection: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
@ -7,23 +7,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"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/models"
|
||||||
"github.com/timetracker/backend/internal/types"
|
"github.com/timetracker/backend/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Get database configuration with sensible defaults
|
// Get database configuration with sensible defaults
|
||||||
dbConfig := config.DefaultDatabaseConfig()
|
dbConfig := models.DefaultDatabaseConfig()
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
fmt.Println("Connecting to database...")
|
fmt.Println("Connecting to database...")
|
||||||
if err := db.InitDB(dbConfig); err != nil {
|
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() {
|
||||||
if err := db.CloseDB(); err != nil {
|
if err := models.CloseDB(); err != nil {
|
||||||
log.Printf("Error closing database connection: %v", err)
|
log.Printf("Error closing database connection: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
@ -7,7 +7,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/config"
|
"github.com/timetracker/backend/internal/config"
|
||||||
"github.com/timetracker/backend/internal/db"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -24,11 +23,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
if err := db.InitDB(cfg.Database); err != nil {
|
if err := models.InitDB(cfg.Database); err != nil {
|
||||||
log.Fatalf("Error initializing database: %v", err)
|
log.Fatalf("Error initializing database: %v", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := db.CloseDB(); err != nil {
|
if err := models.CloseDB(); err != nil {
|
||||||
log.Printf("Error closing database connection: %v", err)
|
log.Printf("Error closing database connection: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@ -45,7 +44,7 @@ func main() {
|
|||||||
func seedDatabase(ctx context.Context) error {
|
func seedDatabase(ctx context.Context) error {
|
||||||
// Check if seeding is needed
|
// Check if seeding is needed
|
||||||
var count int64
|
var count int64
|
||||||
if err := db.GetEngine(ctx).Model(&models.Company{}).Count(&count).Error; err != nil {
|
if err := models.GetEngine(ctx).Model(&models.Company{}).Count(&count).Error; err != nil {
|
||||||
return fmt.Errorf("error checking if seeding is needed: %w", err)
|
return fmt.Errorf("error checking if seeding is needed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +57,7 @@ func seedDatabase(ctx context.Context) error {
|
|||||||
log.Println("Seeding database with initial data...")
|
log.Println("Seeding database with initial data...")
|
||||||
|
|
||||||
// Start transaction
|
// Start transaction
|
||||||
return db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
return models.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Create default company
|
// Create default company
|
||||||
defaultCompany := models.Company{
|
defaultCompany := models.Company{
|
||||||
Name: "Default Company",
|
Name: "Default Company",
|
||||||
|
@ -12,7 +12,7 @@ type ProjectDto struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||||
Name string `json:"name" example:"Time Tracking App"`
|
Name string `json:"name" example:"Time Tracking App"`
|
||||||
CustomerID *string `json:"customerId,omitempty" example:"01HGW2BBG0000000000000000"`
|
CustomerID *string `json:"customerId" example:"01HGW2BBG0000000000000000"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectCreateDto struct {
|
type ProjectCreateDto struct {
|
||||||
|
@ -148,17 +148,13 @@ 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 {
|
||||||
var customerIdPtr *string
|
customerId := project.CustomerID.String()
|
||||||
if project.CustomerID != nil {
|
|
||||||
customerIdStr := project.CustomerID.String()
|
|
||||||
customerIdPtr = &customerIdStr
|
|
||||||
}
|
|
||||||
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: customerIdPtr,
|
CustomerID: &customerId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,13 +182,13 @@ func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto, id types.ULID) (mo
|
|||||||
|
|
||||||
if dto.CustomerID.Valid {
|
if dto.CustomerID.Valid {
|
||||||
if dto.CustomerID.Value == nil {
|
if dto.CustomerID.Value == nil {
|
||||||
update.CustomerID = types.Null[types.ULID]()
|
update.CustomerID = nil
|
||||||
} else {
|
} else {
|
||||||
customerID, err := types.ULIDFromString(*dto.CustomerID.Value)
|
customerID, err := types.ULIDFromString(*dto.CustomerID.Value)
|
||||||
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 = types.NewNullable(customerID)
|
update.CustomerID = &customerID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,7 +73,7 @@ func fileExists(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// generateRSAKeys generates RSA keys and saves them to disk
|
// generateRSAKeys generates RSA keys and saves them to disk
|
||||||
func generateRSAKeys(cfg config.JWTConfig) error {
|
func generateRSAKeys(cfg models.JWTConfig) error {
|
||||||
// Create key directory if it doesn't exist
|
// Create key directory if it doesn't exist
|
||||||
if err := os.MkdirAll(cfg.KeyDir, 0700); err != nil {
|
if err := os.MkdirAll(cfg.KeyDir, 0700); err != nil {
|
||||||
return fmt.Errorf("failed to create key directory: %w", err)
|
return fmt.Errorf("failed to create key directory: %w", err)
|
||||||
|
@ -9,54 +9,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/timetracker/backend/internal/models"
|
||||||
"gorm.io/gorm/logger"
|
"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
|
// Config represents the application configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Database DatabaseConfig
|
Database models.DatabaseConfig
|
||||||
JWTConfig JWTConfig
|
JWTConfig models.JWTConfig
|
||||||
APIKey string
|
APIKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,8 +26,8 @@ func LoadConfig() (*Config, error) {
|
|||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Database: DefaultDatabaseConfig(),
|
Database: models.DefaultDatabaseConfig(),
|
||||||
JWTConfig: JWTConfig{},
|
JWTConfig: models.JWTConfig{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load database configuration
|
// Load database configuration
|
||||||
|
@ -1,126 +0,0 @@
|
|||||||
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,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/db"
|
|
||||||
"github.com/timetracker/backend/internal/types"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -37,7 +36,7 @@ type ActivityCreate struct {
|
|||||||
// GetActivityByID finds an Activity by its ID
|
// GetActivityByID finds an Activity by its ID
|
||||||
func GetActivityByID(ctx context.Context, id types.ULID) (*Activity, error) {
|
func GetActivityByID(ctx context.Context, id types.ULID) (*Activity, error) {
|
||||||
var activity Activity
|
var activity Activity
|
||||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&activity)
|
result := GetEngine(ctx).Where("id = ?", id).First(&activity)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -50,7 +49,7 @@ func GetActivityByID(ctx context.Context, id types.ULID) (*Activity, error) {
|
|||||||
// GetAllActivities returns all Activities
|
// GetAllActivities returns all Activities
|
||||||
func GetAllActivities(ctx context.Context) ([]Activity, error) {
|
func GetAllActivities(ctx context.Context) ([]Activity, error) {
|
||||||
var activities []Activity
|
var activities []Activity
|
||||||
result := db.GetEngine(ctx).Find(&activities)
|
result := GetEngine(ctx).Find(&activities)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -64,7 +63,7 @@ func CreateActivity(ctx context.Context, create ActivityCreate) (*Activity, erro
|
|||||||
BillingRate: create.BillingRate,
|
BillingRate: create.BillingRate,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := db.GetEngine(ctx).Create(&activity)
|
result := GetEngine(ctx).Create(&activity)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -92,6 +91,6 @@ func UpdateActivity(ctx context.Context, update ActivityUpdate) (*Activity, erro
|
|||||||
|
|
||||||
// DeleteActivity deletes an Activity by its ID
|
// DeleteActivity deletes an Activity by its ID
|
||||||
func DeleteActivity(ctx context.Context, id types.ULID) error {
|
func DeleteActivity(ctx context.Context, id types.ULID) error {
|
||||||
result := db.GetEngine(ctx).Delete(&Activity{}, id)
|
result := GetEngine(ctx).Delete(&Activity{}, id)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/db"
|
|
||||||
"github.com/timetracker/backend/internal/types"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -34,7 +33,7 @@ type CompanyUpdate struct {
|
|||||||
// GetCompanyByID finds a company by its ID
|
// GetCompanyByID finds a company by its ID
|
||||||
func GetCompanyByID(ctx context.Context, id types.ULID) (*Company, error) {
|
func GetCompanyByID(ctx context.Context, id types.ULID) (*Company, error) {
|
||||||
var company Company
|
var company Company
|
||||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&company)
|
result := GetEngine(ctx).Where("id = ?", id).First(&company)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -47,7 +46,7 @@ func GetCompanyByID(ctx context.Context, id types.ULID) (*Company, error) {
|
|||||||
// GetAllCompanies returns all companies
|
// GetAllCompanies returns all companies
|
||||||
func GetAllCompanies(ctx context.Context) ([]Company, error) {
|
func GetAllCompanies(ctx context.Context) ([]Company, error) {
|
||||||
var companies []Company
|
var companies []Company
|
||||||
result := db.GetEngine(ctx).Find(&companies)
|
result := GetEngine(ctx).Find(&companies)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -56,7 +55,7 @@ func GetAllCompanies(ctx context.Context) ([]Company, error) {
|
|||||||
|
|
||||||
func GetCustomersByCompanyID(ctx context.Context, companyID int) ([]Customer, error) {
|
func GetCustomersByCompanyID(ctx context.Context, companyID int) ([]Customer, error) {
|
||||||
var customers []Customer
|
var customers []Customer
|
||||||
result := db.GetEngine(ctx).Where("company_id = ?", companyID).Find(&customers)
|
result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&customers)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -69,7 +68,7 @@ func CreateCompany(ctx context.Context, create CompanyCreate) (*Company, error)
|
|||||||
Name: create.Name,
|
Name: create.Name,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := db.GetEngine(ctx).Create(&company)
|
result := GetEngine(ctx).Create(&company)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -97,6 +96,6 @@ func UpdateCompany(ctx context.Context, update CompanyUpdate) (*Company, error)
|
|||||||
|
|
||||||
// DeleteCompany deletes a company by its ID
|
// DeleteCompany deletes a company by its ID
|
||||||
func DeleteCompany(ctx context.Context, id types.ULID) error {
|
func DeleteCompany(ctx context.Context, id types.ULID) error {
|
||||||
result := db.GetEngine(ctx).Delete(&Company{}, id)
|
result := GetEngine(ctx).Delete(&Company{}, id)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/db"
|
|
||||||
"github.com/timetracker/backend/internal/types"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -40,7 +39,7 @@ type CustomerUpdate struct {
|
|||||||
// GetCustomerByID finds a customer by its ID
|
// GetCustomerByID finds a customer by its ID
|
||||||
func GetCustomerByID(ctx context.Context, id types.ULID) (*Customer, error) {
|
func GetCustomerByID(ctx context.Context, id types.ULID) (*Customer, error) {
|
||||||
var customer Customer
|
var customer Customer
|
||||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&customer)
|
result := GetEngine(ctx).Where("id = ?", id).First(&customer)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -53,7 +52,7 @@ func GetCustomerByID(ctx context.Context, id types.ULID) (*Customer, error) {
|
|||||||
// GetAllCustomers returns all customers
|
// GetAllCustomers returns all customers
|
||||||
func GetAllCustomers(ctx context.Context) ([]Customer, error) {
|
func GetAllCustomers(ctx context.Context) ([]Customer, error) {
|
||||||
var customers []Customer
|
var customers []Customer
|
||||||
result := db.GetEngine(ctx).Find(&customers)
|
result := GetEngine(ctx).Find(&customers)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -67,7 +66,7 @@ func CreateCustomer(ctx context.Context, create CustomerCreate) (*Customer, erro
|
|||||||
CompanyID: create.CompanyID,
|
CompanyID: create.CompanyID,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := db.GetEngine(ctx).Create(&customer)
|
result := GetEngine(ctx).Create(&customer)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -95,6 +94,6 @@ func UpdateCustomer(ctx context.Context, update CustomerUpdate) (*Customer, erro
|
|||||||
|
|
||||||
// DeleteCustomer deletes a customer by its ID
|
// DeleteCustomer deletes a customer by its ID
|
||||||
func DeleteCustomer(ctx context.Context, id types.ULID) error {
|
func DeleteCustomer(ctx context.Context, id types.ULID) error {
|
||||||
result := db.GetEngine(ctx).Delete(&Customer{}, id)
|
result := GetEngine(ctx).Delete(&Customer{}, id)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
@ -9,33 +9,101 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/config"
|
"gorm.io/driver/postgres" // For PostgreSQL
|
||||||
"github.com/timetracker/backend/internal/db"
|
|
||||||
"github.com/timetracker/backend/internal/permissions"
|
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"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
|
// MigrateDB performs database migrations for all models
|
||||||
func MigrateDB() error {
|
func MigrateDB() error {
|
||||||
gormDB := db.GetEngine(context.Background())
|
if defaultDB == nil {
|
||||||
if gormDB == nil {
|
|
||||||
return errors.New("database not initialized")
|
return errors.New("database not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Starting database migration...")
|
log.Println("Starting database migration...")
|
||||||
|
|
||||||
// Add all models that should be migrated here
|
// Add all models that should be migrated here
|
||||||
err := gormDB.AutoMigrate(
|
err := defaultDB.AutoMigrate(
|
||||||
&Company{},
|
&Company{},
|
||||||
&User{},
|
&User{},
|
||||||
&Customer{},
|
&Customer{},
|
||||||
&Project{},
|
&Project{},
|
||||||
&Activity{},
|
&Activity{},
|
||||||
&TimeEntry{},
|
&TimeEntry{},
|
||||||
&permissions.Role{},
|
|
||||||
&permissions.Policy{},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -46,8 +114,34 @@ func MigrateDB() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGormDB is used for special cases like database creation
|
// GetEngine returns the DB instance, possibly with context
|
||||||
func GetGormDB(dbConfig config.DatabaseConfig, dbName string) (*gorm.DB, error) {
|
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) {
|
||||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
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)
|
dbConfig.Host, dbConfig.Port, dbConfig.User, dbConfig.Password, dbName, dbConfig.SSLMode)
|
||||||
|
|
||||||
@ -90,7 +184,7 @@ func UpdateModel(ctx context.Context, model any, updates any) error {
|
|||||||
updateMap := make(map[string]any)
|
updateMap := make(map[string]any)
|
||||||
|
|
||||||
// Iterate through all fields
|
// Iterate through all fields
|
||||||
for i := range updateValue.NumField() {
|
for i := 0; i < updateValue.NumField(); i++ {
|
||||||
field := updateValue.Field(i)
|
field := updateValue.Field(i)
|
||||||
fieldType := updateType.Field(i)
|
fieldType := updateType.Field(i)
|
||||||
|
|
||||||
@ -129,14 +223,5 @@ func UpdateModel(ctx context.Context, model any, updates any) error {
|
|||||||
return nil // Nothing to update
|
return nil // Nothing to update
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.GetEngine(ctx).Model(model).Updates(updateMap).Error
|
return 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,4 +1,13 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
// This file is intentionally left empty.
|
import "time"
|
||||||
// The JWTConfig struct has been moved to the config package.
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/db"
|
|
||||||
"github.com/timetracker/backend/internal/types"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -14,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 *types.ULID `gorm:"column:customer_id;type:bytea;index"`
|
CustomerID *types.ULID `gorm:"column:customer_id;type:bytea;not null"`
|
||||||
|
|
||||||
// Relationships (for Eager Loading)
|
// Relationships (for Eager Loading)
|
||||||
Customer *Customer `gorm:"foreignKey:CustomerID"`
|
Customer *Customer `gorm:"foreignKey:CustomerID"`
|
||||||
@ -35,7 +34,7 @@ type ProjectCreate struct {
|
|||||||
type ProjectUpdate struct {
|
type ProjectUpdate struct {
|
||||||
ID types.ULID `gorm:"-"` // Exclude from updates
|
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||||
Name *string `gorm:"column:name"`
|
Name *string `gorm:"column:name"`
|
||||||
CustomerID types.Nullable[types.ULID] `gorm:"column:customer_id"`
|
CustomerID *types.ULID `gorm:"column:customer_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the Create struct contains valid data
|
// Validate checks if the Create struct contains valid data
|
||||||
@ -44,7 +43,7 @@ func (pc *ProjectCreate) Validate() error {
|
|||||||
return errors.New("project name cannot be empty")
|
return errors.New("project name cannot be empty")
|
||||||
}
|
}
|
||||||
// Check for valid CustomerID
|
// Check for valid CustomerID
|
||||||
if pc.CustomerID != nil && pc.CustomerID.Compare(types.ULID{}) == 0 {
|
if pc.CustomerID.Compare(types.ULID{}) == 0 {
|
||||||
return errors.New("customerID cannot be empty")
|
return errors.New("customerID cannot be empty")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -61,7 +60,7 @@ func (pu *ProjectUpdate) Validate() error {
|
|||||||
// GetProjectByID finds a project by its ID
|
// GetProjectByID finds a project by its ID
|
||||||
func GetProjectByID(ctx context.Context, id types.ULID) (*Project, error) {
|
func GetProjectByID(ctx context.Context, id types.ULID) (*Project, error) {
|
||||||
var project Project
|
var project Project
|
||||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&project)
|
result := GetEngine(ctx).Where("id = ?", id).First(&project)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -74,7 +73,7 @@ func GetProjectByID(ctx context.Context, id types.ULID) (*Project, error) {
|
|||||||
// GetProjectWithCustomer loads a project with the associated customer information
|
// GetProjectWithCustomer loads a project with the associated customer information
|
||||||
func GetProjectWithCustomer(ctx context.Context, id types.ULID) (*Project, error) {
|
func GetProjectWithCustomer(ctx context.Context, id types.ULID) (*Project, error) {
|
||||||
var project Project
|
var project Project
|
||||||
result := db.GetEngine(ctx).Preload("Customer").Where("id = ?", id).First(&project)
|
result := GetEngine(ctx).Preload("Customer").Where("id = ?", id).First(&project)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -87,7 +86,7 @@ func GetProjectWithCustomer(ctx context.Context, id types.ULID) (*Project, error
|
|||||||
// GetAllProjects returns all projects
|
// GetAllProjects returns all projects
|
||||||
func GetAllProjects(ctx context.Context) ([]Project, error) {
|
func GetAllProjects(ctx context.Context) ([]Project, error) {
|
||||||
var projects []Project
|
var projects []Project
|
||||||
result := db.GetEngine(ctx).Find(&projects)
|
result := GetEngine(ctx).Find(&projects)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -97,7 +96,7 @@ func GetAllProjects(ctx context.Context) ([]Project, error) {
|
|||||||
// GetAllProjectsWithCustomers returns all projects with customer information
|
// GetAllProjectsWithCustomers returns all projects with customer information
|
||||||
func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) {
|
func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) {
|
||||||
var projects []Project
|
var projects []Project
|
||||||
result := db.GetEngine(ctx).Preload("Customer").Find(&projects)
|
result := GetEngine(ctx).Preload("Customer").Find(&projects)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -107,7 +106,7 @@ func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) {
|
|||||||
// GetProjectsByCustomerID returns all projects of a specific customer
|
// GetProjectsByCustomerID returns all projects of a specific customer
|
||||||
func GetProjectsByCustomerID(ctx context.Context, customerId types.ULID) ([]Project, error) {
|
func GetProjectsByCustomerID(ctx context.Context, customerId types.ULID) ([]Project, error) {
|
||||||
var projects []Project
|
var projects []Project
|
||||||
result := db.GetEngine(ctx).Where("customer_id = ?", customerId.ULID).Find(&projects)
|
result := GetEngine(ctx).Where("customer_id = ?", customerId.ULID).Find(&projects)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -122,7 +121,7 @@ func CreateProject(ctx context.Context, create ProjectCreate) (*Project, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the customer exists
|
// Check if the customer exists
|
||||||
if create.CustomerID != nil {
|
if create.CustomerID == nil {
|
||||||
customer, err := GetCustomerByID(ctx, *create.CustomerID)
|
customer, err := GetCustomerByID(ctx, *create.CustomerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error checking the customer: %w", err)
|
return nil, fmt.Errorf("error checking the customer: %w", err)
|
||||||
@ -137,7 +136,7 @@ func CreateProject(ctx context.Context, create ProjectCreate) (*Project, error)
|
|||||||
CustomerID: create.CustomerID,
|
CustomerID: create.CustomerID,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := db.GetEngine(ctx).Create(&project)
|
result := GetEngine(ctx).Create(&project)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, fmt.Errorf("error creating the project: %w", result.Error)
|
return nil, fmt.Errorf("error creating the project: %w", result.Error)
|
||||||
}
|
}
|
||||||
@ -160,19 +159,14 @@ func UpdateProject(ctx context.Context, update ProjectUpdate) (*Project, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If CustomerID is updated, check if the customer exists
|
// If CustomerID is updated, check if the customer exists
|
||||||
if update.CustomerID.Valid {
|
if update.CustomerID != nil {
|
||||||
if update.CustomerID.Value != nil {
|
customer, err := GetCustomerByID(ctx, *update.CustomerID)
|
||||||
customer, err := GetCustomerByID(ctx, *update.CustomerID.Value)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error checking the customer: %w", err)
|
return nil, fmt.Errorf("error checking the customer: %w", err)
|
||||||
}
|
}
|
||||||
if customer == nil {
|
if customer == nil {
|
||||||
return nil, errors.New("the specified customer does not exist")
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use generic update function
|
// Use generic update function
|
||||||
@ -187,7 +181,7 @@ func UpdateProject(ctx context.Context, update ProjectUpdate) (*Project, error)
|
|||||||
// DeleteProject deletes a project by its ID
|
// DeleteProject deletes a project by its ID
|
||||||
func DeleteProject(ctx context.Context, id types.ULID) error {
|
func DeleteProject(ctx context.Context, id types.ULID) error {
|
||||||
// Here you could check if dependent entities exist
|
// Here you could check if dependent entities exist
|
||||||
result := db.GetEngine(ctx).Delete(&Project{}, id)
|
result := GetEngine(ctx).Delete(&Project{}, id)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return fmt.Errorf("error deleting the project: %w", result.Error)
|
return fmt.Errorf("error deleting the project: %w", result.Error)
|
||||||
}
|
}
|
||||||
@ -204,7 +198,7 @@ func CreateProjectWithTransaction(ctx context.Context, create ProjectCreate) (*P
|
|||||||
var project *Project
|
var project *Project
|
||||||
|
|
||||||
// Start transaction
|
// Start transaction
|
||||||
err := db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Customer check within the transaction
|
// Customer check within the transaction
|
||||||
var customer Customer
|
var customer Customer
|
||||||
if err := tx.Where("id = ?", create.CustomerID).First(&customer).Error; err != nil {
|
if err := tx.Where("id = ?", create.CustomerID).First(&customer).Error; err != nil {
|
||||||
|
@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/db"
|
|
||||||
"github.com/timetracker/backend/internal/types"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -106,7 +105,7 @@ func (tu *TimeEntryUpdate) Validate() error {
|
|||||||
// GetTimeEntryByID finds a time entry by its ID
|
// GetTimeEntryByID finds a time entry by its ID
|
||||||
func GetTimeEntryByID(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
func GetTimeEntryByID(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
||||||
var timeEntry TimeEntry
|
var timeEntry TimeEntry
|
||||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
|
result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -119,7 +118,7 @@ func GetTimeEntryByID(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
|||||||
// GetTimeEntryWithRelations loads a time entry with all associated data
|
// GetTimeEntryWithRelations loads a time entry with all associated data
|
||||||
func GetTimeEntryWithRelations(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
func GetTimeEntryWithRelations(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
||||||
var timeEntry TimeEntry
|
var timeEntry TimeEntry
|
||||||
result := db.GetEngine(ctx).
|
result := GetEngine(ctx).
|
||||||
Preload("User").
|
Preload("User").
|
||||||
Preload("Project").
|
Preload("Project").
|
||||||
Preload("Project.Customer"). // Nested relationship
|
Preload("Project.Customer"). // Nested relationship
|
||||||
@ -139,7 +138,7 @@ func GetTimeEntryWithRelations(ctx context.Context, id types.ULID) (*TimeEntry,
|
|||||||
// GetAllTimeEntries returns all time entries
|
// GetAllTimeEntries returns all time entries
|
||||||
func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) {
|
func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) {
|
||||||
var timeEntries []TimeEntry
|
var timeEntries []TimeEntry
|
||||||
result := db.GetEngine(ctx).Find(&timeEntries)
|
result := GetEngine(ctx).Find(&timeEntries)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -149,7 +148,7 @@ func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) {
|
|||||||
// GetTimeEntriesByUserID returns all time entries of a user
|
// GetTimeEntriesByUserID returns all time entries of a user
|
||||||
func GetTimeEntriesByUserID(ctx context.Context, userID types.ULID) ([]TimeEntry, error) {
|
func GetTimeEntriesByUserID(ctx context.Context, userID types.ULID) ([]TimeEntry, error) {
|
||||||
var timeEntries []TimeEntry
|
var timeEntries []TimeEntry
|
||||||
result := db.GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries)
|
result := GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -159,7 +158,7 @@ func GetTimeEntriesByUserID(ctx context.Context, userID types.ULID) ([]TimeEntry
|
|||||||
// GetTimeEntriesByProjectID returns all time entries of a project
|
// GetTimeEntriesByProjectID returns all time entries of a project
|
||||||
func GetTimeEntriesByProjectID(ctx context.Context, projectID types.ULID) ([]TimeEntry, error) {
|
func GetTimeEntriesByProjectID(ctx context.Context, projectID types.ULID) ([]TimeEntry, error) {
|
||||||
var timeEntries []TimeEntry
|
var timeEntries []TimeEntry
|
||||||
result := db.GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries)
|
result := GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -170,7 +169,7 @@ func GetTimeEntriesByProjectID(ctx context.Context, projectID types.ULID) ([]Tim
|
|||||||
func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) {
|
func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) {
|
||||||
var timeEntries []TimeEntry
|
var timeEntries []TimeEntry
|
||||||
// Search for overlaps in the time range
|
// Search for overlaps in the time range
|
||||||
result := db.GetEngine(ctx).
|
result := GetEngine(ctx).
|
||||||
Where("(start BETWEEN ? AND ?) OR (end BETWEEN ? AND ?)",
|
Where("(start BETWEEN ? AND ?) OR (end BETWEEN ? AND ?)",
|
||||||
start, end, start, end).
|
start, end, start, end).
|
||||||
Find(&timeEntries)
|
Find(&timeEntries)
|
||||||
@ -190,7 +189,7 @@ func SumBillableHoursByProject(ctx context.Context, projectID types.ULID) (float
|
|||||||
var result Result
|
var result Result
|
||||||
|
|
||||||
// SQL calculation of weighted hours
|
// SQL calculation of weighted hours
|
||||||
err := db.GetEngine(ctx).Raw(`
|
err := GetEngine(ctx).Raw(`
|
||||||
SELECT SUM(
|
SELECT SUM(
|
||||||
EXTRACT(EPOCH FROM (end - start)) / 3600 * (billable / 100.0)
|
EXTRACT(EPOCH FROM (end - start)) / 3600 * (billable / 100.0)
|
||||||
) as total_hours
|
) as total_hours
|
||||||
@ -215,7 +214,7 @@ func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, e
|
|||||||
// Start a transaction
|
// Start a transaction
|
||||||
var timeEntry *TimeEntry
|
var timeEntry *TimeEntry
|
||||||
|
|
||||||
err := db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Check references
|
// Check references
|
||||||
if err := validateReferences(tx, create.UserID, create.ProjectID, create.ActivityID); err != nil {
|
if err := validateReferences(tx, create.UserID, create.ProjectID, create.ActivityID); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -296,7 +295,7 @@ func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start a transaction for the update
|
// Start a transaction for the update
|
||||||
err = db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Check references if they are updated
|
// Check references if they are updated
|
||||||
if update.UserID != nil || update.ProjectID != nil || update.ActivityID != nil {
|
if update.UserID != nil || update.ProjectID != nil || update.ActivityID != nil {
|
||||||
// Use current values if not updated
|
// Use current values if not updated
|
||||||
@ -353,7 +352,7 @@ func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, e
|
|||||||
|
|
||||||
// DeleteTimeEntry deletes a time entry by its ID
|
// DeleteTimeEntry deletes a time entry by its ID
|
||||||
func DeleteTimeEntry(ctx context.Context, id types.ULID) error {
|
func DeleteTimeEntry(ctx context.Context, id types.ULID) error {
|
||||||
result := db.GetEngine(ctx).Delete(&TimeEntry{}, id)
|
result := GetEngine(ctx).Delete(&TimeEntry{}, id)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return fmt.Errorf("error deleting the time entry: %w", result.Error)
|
return fmt.Errorf("error deleting the time entry: %w", result.Error)
|
||||||
}
|
}
|
||||||
|
@ -11,12 +11,28 @@ import (
|
|||||||
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/db"
|
|
||||||
"github.com/timetracker/backend/internal/types"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
"gorm.io/gorm"
|
"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
|
// User represents a user in the system
|
||||||
type User struct {
|
type User struct {
|
||||||
EntityBase
|
EntityBase
|
||||||
@ -26,7 +42,6 @@ type User struct {
|
|||||||
Role string `gorm:"column:role;not null;default:'user'"`
|
Role string `gorm:"column:role;not null;default:'user'"`
|
||||||
CompanyID *types.ULID `gorm:"column:company_id;type:bytea;index"`
|
CompanyID *types.ULID `gorm:"column:company_id;type:bytea;index"`
|
||||||
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
||||||
Companies []string `gorm:"type:text[]"`
|
|
||||||
|
|
||||||
// Relationship for Eager Loading
|
// Relationship for Eager Loading
|
||||||
Company *Company `gorm:"foreignKey:CompanyID"`
|
Company *Company `gorm:"foreignKey:CompanyID"`
|
||||||
@ -187,7 +202,7 @@ func (uc *UserCreate) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if uc.CompanyID != nil && uc.CompanyID.Compare(types.ULID{}) == 0 {
|
if uc.CompanyID.Compare(types.ULID{}) == 0 {
|
||||||
return errors.New("companyID cannot be empty")
|
return errors.New("companyID cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,7 +290,7 @@ func (uu *UserUpdate) Validate() error {
|
|||||||
// GetUserByID finds a user by their ID
|
// GetUserByID finds a user by their ID
|
||||||
func GetUserByID(ctx context.Context, id types.ULID) (*User, error) {
|
func GetUserByID(ctx context.Context, id types.ULID) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&user)
|
result := GetEngine(ctx).Where("id = ?", id).First(&user)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -288,7 +303,7 @@ func GetUserByID(ctx context.Context, id types.ULID) (*User, error) {
|
|||||||
// GetUserByEmail finds a user by their email
|
// GetUserByEmail finds a user by their email
|
||||||
func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
result := db.GetEngine(ctx).Where("email = ?", email).First(&user)
|
result := GetEngine(ctx).Where("email = ?", email).First(&user)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -301,7 +316,7 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
|||||||
// GetUserWithCompany loads a user with their company
|
// GetUserWithCompany loads a user with their company
|
||||||
func GetUserWithCompany(ctx context.Context, id types.ULID) (*User, error) {
|
func GetUserWithCompany(ctx context.Context, id types.ULID) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
result := db.GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user)
|
result := GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -314,7 +329,7 @@ func GetUserWithCompany(ctx context.Context, id types.ULID) (*User, error) {
|
|||||||
// GetAllUsers returns all users
|
// GetAllUsers returns all users
|
||||||
func GetAllUsers(ctx context.Context) ([]User, error) {
|
func GetAllUsers(ctx context.Context) ([]User, error) {
|
||||||
var users []User
|
var users []User
|
||||||
result := db.GetEngine(ctx).Find(&users)
|
result := GetEngine(ctx).Find(&users)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -336,7 +351,7 @@ func GetUsersByCompanyID(ctx context.Context, companyID types.ULID) ([]User, err
|
|||||||
var users []User
|
var users []User
|
||||||
// Apply the dynamic company condition
|
// Apply the dynamic company condition
|
||||||
condition := getCompanyCondition(&companyID)
|
condition := getCompanyCondition(&companyID)
|
||||||
result := db.GetEngine(ctx).Scopes(condition).Find(&users)
|
result := GetEngine(ctx).Scopes(condition).Find(&users)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@ -353,7 +368,7 @@ func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
|
|||||||
// Start a transaction
|
// Start a transaction
|
||||||
var user *User
|
var user *User
|
||||||
|
|
||||||
err := db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Check if email already exists
|
// Check if email already exists
|
||||||
var count int64
|
var count int64
|
||||||
if err := tx.Model(&User{}).Where("email = ?", create.Email).Count(&count).Error; err != nil {
|
if err := tx.Model(&User{}).Where("email = ?", create.Email).Count(&count).Error; err != nil {
|
||||||
@ -363,7 +378,6 @@ func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
|
|||||||
return errors.New("email is already in use")
|
return errors.New("email is already in use")
|
||||||
}
|
}
|
||||||
|
|
||||||
if create.CompanyID != nil {
|
|
||||||
// Check if company exists
|
// Check if company exists
|
||||||
var companyCount int64
|
var companyCount int64
|
||||||
if err := tx.Model(&Company{}).Where("id = ?", create.CompanyID).Count(&companyCount).Error; err != nil {
|
if err := tx.Model(&Company{}).Where("id = ?", create.CompanyID).Count(&companyCount).Error; err != nil {
|
||||||
@ -372,7 +386,6 @@ func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
|
|||||||
if companyCount == 0 {
|
if companyCount == 0 {
|
||||||
return errors.New("the specified company does not exist")
|
return errors.New("the specified company does not exist")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password with unique salt
|
// Hash password with unique salt
|
||||||
pwData, err := HashPassword(create.Password)
|
pwData, err := HashPassword(create.Password)
|
||||||
@ -422,7 +435,7 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start a transaction for the update
|
// Start a transaction for the update
|
||||||
err = db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// If email is updated, check if it's already in use
|
// If email is updated, check if it's already in use
|
||||||
if update.Email != nil && *update.Email != user.Email {
|
if update.Email != nil && *update.Email != user.Email {
|
||||||
var count int64
|
var count int64
|
||||||
@ -479,6 +492,7 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
|
|||||||
} else {
|
} else {
|
||||||
updates["company_id"] = *update.CompanyID.Value
|
updates["company_id"] = *update.CompanyID.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
if update.HourlyRate != nil {
|
if update.HourlyRate != nil {
|
||||||
updates["hourly_rate"] = *update.HourlyRate
|
updates["hourly_rate"] = *update.HourlyRate
|
||||||
@ -507,7 +521,7 @@ func DeleteUser(ctx context.Context, id types.ULID) error {
|
|||||||
// Here one could check if dependent entities exist
|
// Here one could check if dependent entities exist
|
||||||
// e.g., don't delete if time entries still exist
|
// e.g., don't delete if time entries still exist
|
||||||
|
|
||||||
result := db.GetEngine(ctx).Delete(&User{}, id)
|
result := GetEngine(ctx).Delete(&User{}, id)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return fmt.Errorf("error deleting user: %w", result.Error)
|
return fmt.Errorf("error deleting user: %w", result.Error)
|
||||||
}
|
}
|
||||||
@ -537,20 +551,3 @@ func AuthenticateUser(ctx context.Context, email, password string) (*User, error
|
|||||||
|
|
||||||
return user, nil
|
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"
|
|
||||||
)
|
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
package permissions
|
|
||||||
|
|
||||||
type Permission uint64
|
|
||||||
|
|
||||||
const (
|
|
||||||
PermRead Permission = 1 << iota // 1
|
|
||||||
PermWrite // 2
|
|
||||||
PermCreate // 4
|
|
||||||
PermList // 8
|
|
||||||
PermDelete // 16
|
|
||||||
PermModerate // 32
|
|
||||||
PermSuperadmin // 64
|
|
||||||
)
|
|
@ -1,40 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
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
BIN
backend/migrate
Binary file not shown.
@ -1,168 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,168 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,200 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,225 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,292 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,241 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,250 +0,0 @@
|
|||||||
# 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
113
flake.nix
@ -1,113 +0,0 @@
|
|||||||
{
|
|
||||||
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