Compare commits
No commits in common. "e336ff3ba2ef5e8031aa89b49724f34fdabc56ed" and "78be762430f515d0a4ef2d94a2d14b919f8c73fe" have entirely different histories.
e336ff3ba2
...
78be762430
49
.clinerules
49
.clinerules
@ -1,49 +0,0 @@
|
|||||||
# TimeTracker Project Rules (v2)
|
|
||||||
|
|
||||||
1. ARCHITECTURE
|
|
||||||
- Multi-tenancy enforced via company_id in all DB queries
|
|
||||||
- FPGO/FPTS patterns required for service layer implementations
|
|
||||||
|
|
||||||
2. CODING PRACTICES
|
|
||||||
- Type safety enforced (Go 1.21+ generics, TypeScript strict mode)
|
|
||||||
- Domain types must match across backend (Go) and frontend (TypeScript)
|
|
||||||
- All database access through repository interfaces
|
|
||||||
- API handlers must use DTOs for input/output
|
|
||||||
- Use tygo to generate TypeScript types after modifying Go types
|
|
||||||
|
|
||||||
3. SECURITY
|
|
||||||
- JWT authentication required for all API endpoints
|
|
||||||
- RBAC implemented in middleware/auth.go
|
|
||||||
- Input validation using github.com/go-playground/validator
|
|
||||||
- No raw SQL - use GORM query builder
|
|
||||||
|
|
||||||
4. DOCUMENTATION
|
|
||||||
- Swagger docs updated with all API changes
|
|
||||||
- Architecture decisions recorded in docu/ARCHITECTURE.md
|
|
||||||
- Type relationships documented in docu/domain_types.md
|
|
||||||
|
|
||||||
5. TESTING
|
|
||||||
- 80%+ test coverage for domain logic
|
|
||||||
- Integration tests for API endpoints
|
|
||||||
- Model tests in backend/cmd/modeltest
|
|
||||||
|
|
||||||
6. FRONTEND
|
|
||||||
- Next.js App Router pattern required
|
|
||||||
- Server components for data fetching
|
|
||||||
- Client components must use TanStack Query
|
|
||||||
- UI state management via Zustand
|
|
||||||
|
|
||||||
|
|
||||||
8. DEVELOPMENT WORKFLOW
|
|
||||||
- Makefile commands are only available in the backend folder
|
|
||||||
- Common make commands:
|
|
||||||
- make generate: Run code generation (tygo, swagger, etc.)
|
|
||||||
- make test: Run all tests
|
|
||||||
- make build: Build the application
|
|
||||||
|
|
||||||
9. CUSTOM RULES
|
|
||||||
- Add custom rules to .clinerules if:
|
|
||||||
- Unexpected behavior is encountered
|
|
||||||
- Specific conditions require warnings
|
|
||||||
- New patterns emerge that need documentation
|
|
||||||
- make run: Start the development server
|
|
6
.env
6
.env
@ -1,6 +0,0 @@
|
|||||||
DB_HOST=localhost
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_USER=timetracker
|
|
||||||
DB_PASSWORD=password
|
|
||||||
DB_NAME=timetracker
|
|
||||||
DB_SSLMODE=disable
|
|
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
# Time Tracker Backend Makefile
|
# Time Tracker Backend Makefile
|
||||||
|
|
||||||
.PHONY: db-start db-stop db-test model-test run build clean migrate seed help
|
.PHONY: db-start db-stop db-test model-test run build clean migrate seed help
|
||||||
|
62
backend/cmd/api/docs/docs.go
Normal file
62
backend/cmd/api/docs/docs.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import "github.com/swaggo/swag"
|
||||||
|
|
||||||
|
const docTemplate = `{
|
||||||
|
"schemes": {{ marshal .Schemes }},
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": {
|
||||||
|
"description": "{{escape .Description}}",
|
||||||
|
"title": "{{.Title}}",
|
||||||
|
"contact": {},
|
||||||
|
"version": "{{.Version}}"
|
||||||
|
},
|
||||||
|
"host": "{{.Host}}",
|
||||||
|
"basePath": "{{.BasePath}}",
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get a hello message",
|
||||||
|
"produces": [
|
||||||
|
"text/plain"
|
||||||
|
],
|
||||||
|
"summary": "Say hello",
|
||||||
|
"operationId": "hello",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Hello from the Time Tracker Backend!",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"securityDefinitions": {
|
||||||
|
"BearerAuth": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||||
|
var SwaggerInfo = &swag.Spec{
|
||||||
|
Version: "1.0",
|
||||||
|
Host: "localhost:8080",
|
||||||
|
BasePath: "/api",
|
||||||
|
Schemes: []string{},
|
||||||
|
Title: "Time Tracker API",
|
||||||
|
Description: "This is a simple time tracker API.",
|
||||||
|
InfoInstanceName: "swagger",
|
||||||
|
SwaggerTemplate: docTemplate,
|
||||||
|
LeftDelim: "{{",
|
||||||
|
RightDelim: "}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||||
|
}
|
38
backend/cmd/api/docs/swagger.json
Normal file
38
backend/cmd/api/docs/swagger.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": {
|
||||||
|
"description": "This is a simple time tracker API.",
|
||||||
|
"title": "Time Tracker API",
|
||||||
|
"contact": {},
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
"host": "localhost:8080",
|
||||||
|
"basePath": "/api",
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get a hello message",
|
||||||
|
"produces": [
|
||||||
|
"text/plain"
|
||||||
|
],
|
||||||
|
"summary": "Say hello",
|
||||||
|
"operationId": "hello",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Hello from the Time Tracker Backend!",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"securityDefinitions": {
|
||||||
|
"BearerAuth": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
backend/cmd/api/docs/swagger.yaml
Normal file
26
backend/cmd/api/docs/swagger.yaml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
basePath: /api
|
||||||
|
host: localhost:8080
|
||||||
|
info:
|
||||||
|
contact: {}
|
||||||
|
description: This is a simple time tracker API.
|
||||||
|
title: Time Tracker API
|
||||||
|
version: "1.0"
|
||||||
|
paths:
|
||||||
|
/:
|
||||||
|
get:
|
||||||
|
description: Get a hello message
|
||||||
|
operationId: hello
|
||||||
|
produces:
|
||||||
|
- text/plain
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Hello from the Time Tracker Backend!
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: Say hello
|
||||||
|
securityDefinitions:
|
||||||
|
BearerAuth:
|
||||||
|
in: header
|
||||||
|
name: Authorization
|
||||||
|
type: apiKey
|
||||||
|
swagger: "2.0"
|
@ -15,9 +15,10 @@ import (
|
|||||||
ginSwagger "github.com/swaggo/gin-swagger"
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
_ "github.com/timetracker/backend/docs" // This line is important for swag to work
|
_ "github.com/timetracker/backend/docs" // This line is important for swag to work
|
||||||
"github.com/timetracker/backend/internal/api/routes"
|
"github.com/timetracker/backend/internal/api/routes"
|
||||||
"github.com/timetracker/backend/internal/config"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
_ "gorm.io/driver/postgres"
|
_ "gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
// GORM IMPORTS MARKER
|
||||||
)
|
)
|
||||||
|
|
||||||
// @title Time Tracker API
|
// @title Time Tracker API
|
||||||
@ -40,13 +41,41 @@ func helloHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.LoadConfig()
|
// Get database configuration with sensible defaults
|
||||||
if err != nil {
|
dbConfig := models.DefaultDatabaseConfig()
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
|
||||||
|
// Override with environment variables if provided
|
||||||
|
if host := os.Getenv("DB_HOST"); host != "" {
|
||||||
|
dbConfig.Host = host
|
||||||
|
}
|
||||||
|
if port := os.Getenv("DB_PORT"); port != "" {
|
||||||
|
var portInt int
|
||||||
|
if _, err := fmt.Sscanf(port, "%d", &portInt); err == nil && portInt > 0 {
|
||||||
|
dbConfig.Port = portInt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user := os.Getenv("DB_USER"); user != "" {
|
||||||
|
dbConfig.User = user
|
||||||
|
}
|
||||||
|
if password := os.Getenv("DB_PASSWORD"); password != "" {
|
||||||
|
dbConfig.Password = password
|
||||||
|
}
|
||||||
|
if dbName := os.Getenv("DB_NAME"); dbName != "" {
|
||||||
|
dbConfig.DBName = dbName
|
||||||
|
}
|
||||||
|
if sslMode := os.Getenv("DB_SSLMODE"); sslMode != "" {
|
||||||
|
dbConfig.SSLMode = sslMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set log level based on environment
|
||||||
|
if gin.Mode() == gin.ReleaseMode {
|
||||||
|
dbConfig.LogLevel = logger.Error // Only log errors in production
|
||||||
|
} else {
|
||||||
|
dbConfig.LogLevel = logger.Info // Log more in development
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
if err := models.InitDB(cfg.Database); 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() {
|
||||||
@ -60,6 +89,12 @@ func main() {
|
|||||||
log.Fatalf("Error migrating database: %v", err)
|
log.Fatalf("Error migrating database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed database with initial data if needed
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := models.SeedDB(ctx); err != nil {
|
||||||
|
log.Fatalf("Error seeding database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create Gin router
|
// Create Gin router
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
@ -196,7 +196,7 @@ func testRelationships(ctx context.Context) {
|
|||||||
|
|
||||||
// Test invalid ID
|
// Test invalid ID
|
||||||
invalidID := ulid.MustNew(ulid.Timestamp(time.Now()), ulid.DefaultEntropy())
|
invalidID := ulid.MustNew(ulid.Timestamp(time.Now()), ulid.DefaultEntropy())
|
||||||
invalidUser, err := models.GetUserByID(ctx, models.FromULID(invalidID))
|
invalidUser, err := models.GetUserByID(ctx, invalidID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error getting user with invalid ID: %v", err)
|
log.Fatalf("Error getting user with invalid ID: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -2,28 +2,56 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/timetracker/backend/internal/config"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Parse CLI flags
|
// Parse command line flags
|
||||||
_ = flag.String("config", "", "Path to .env config file")
|
force := false
|
||||||
flag.Parse()
|
for _, arg := range os.Args[1:] {
|
||||||
|
if arg == "--force" || arg == "-f" {
|
||||||
// Load configuration
|
force = true
|
||||||
cfg, err := config.LoadConfig()
|
}
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get database configuration with sensible defaults
|
||||||
|
dbConfig := models.DefaultDatabaseConfig()
|
||||||
|
|
||||||
|
// Override with environment variables if provided
|
||||||
|
if host := os.Getenv("DB_HOST"); host != "" {
|
||||||
|
dbConfig.Host = host
|
||||||
|
}
|
||||||
|
if port := os.Getenv("DB_PORT"); port != "" {
|
||||||
|
var portInt int
|
||||||
|
if _, err := fmt.Sscanf(port, "%d", &portInt); err == nil && portInt > 0 {
|
||||||
|
dbConfig.Port = portInt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user := os.Getenv("DB_USER"); user != "" {
|
||||||
|
dbConfig.User = user
|
||||||
|
}
|
||||||
|
if password := os.Getenv("DB_PASSWORD"); password != "" {
|
||||||
|
dbConfig.Password = password
|
||||||
|
}
|
||||||
|
if dbName := os.Getenv("DB_NAME"); dbName != "" {
|
||||||
|
dbConfig.DBName = dbName
|
||||||
|
}
|
||||||
|
if sslMode := os.Getenv("DB_SSLMODE"); sslMode != "" {
|
||||||
|
dbConfig.SSLMode = sslMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set log level
|
||||||
|
dbConfig.LogLevel = logger.Info
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
if err := models.InitDB(cfg.Database); err != nil {
|
fmt.Println("Connecting to database...")
|
||||||
|
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() {
|
||||||
@ -31,62 +59,31 @@ func main() {
|
|||||||
log.Printf("Error closing database connection: %v", err)
|
log.Printf("Error closing database connection: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
fmt.Println("✓ Database connection successful")
|
||||||
|
|
||||||
// Execute seed operation
|
// Create context with timeout
|
||||||
if err := seedDatabase(context.Background()); err != nil {
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Check if we need to seed (e.g., no companies exist)
|
||||||
|
if !force {
|
||||||
|
var count int64
|
||||||
|
db := models.GetEngine(ctx)
|
||||||
|
if err := db.Model(&models.Company{}).Count(&count).Error; err != nil {
|
||||||
|
log.Fatalf("Error checking if seeding is needed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If data already exists, skip seeding
|
||||||
|
if count > 0 {
|
||||||
|
fmt.Println("Database already contains data. Use --force to override.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the database
|
||||||
|
fmt.Println("Seeding database with initial data...")
|
||||||
|
if err := models.SeedDB(ctx); err != nil {
|
||||||
log.Fatalf("Error seeding database: %v", err)
|
log.Fatalf("Error seeding database: %v", err)
|
||||||
}
|
}
|
||||||
|
fmt.Println("✓ Database seeding completed successfully")
|
||||||
log.Println("Database seeding completed successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedDatabase performs the database seeding operation
|
|
||||||
func seedDatabase(ctx context.Context) error {
|
|
||||||
// Check if seeding is needed
|
|
||||||
var count int64
|
|
||||||
if err := models.GetEngine(ctx).Model(&models.Company{}).Count(&count).Error; err != nil {
|
|
||||||
return fmt.Errorf("error checking if seeding is needed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If data exists, skip seeding
|
|
||||||
if count > 0 {
|
|
||||||
log.Println("Database already contains data, skipping seeding")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Seeding database with initial data...")
|
|
||||||
|
|
||||||
// Start transaction
|
|
||||||
return models.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Create default company
|
|
||||||
defaultCompany := models.Company{
|
|
||||||
Name: "Default Company",
|
|
||||||
}
|
|
||||||
if err := tx.Create(&defaultCompany).Error; err != nil {
|
|
||||||
return fmt.Errorf("error creating default company: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create admin user
|
|
||||||
adminUser := models.User{
|
|
||||||
Email: "admin@example.com",
|
|
||||||
Role: models.RoleAdmin,
|
|
||||||
CompanyID: defaultCompany.ID,
|
|
||||||
HourlyRate: 100.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
pwData, err := models.HashPassword("Admin@123456")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error hashing password: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
adminUser.Salt = pwData.Salt
|
|
||||||
adminUser.Hash = pwData.Hash
|
|
||||||
|
|
||||||
if err := tx.Create(&adminUser).Error; err != nil {
|
|
||||||
return fmt.Errorf("error creating admin user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,6 @@ go 1.23.6
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
github.com/oklog/ulid/v2 v2.1.0
|
github.com/oklog/ulid/v2 v2.1.0
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
github.com/swaggo/gin-swagger v1.6.0
|
github.com/swaggo/gin-swagger v1.6.0
|
||||||
|
@ -54,8 +54,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
|||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
@ -72,7 +72,7 @@ func (h *ActivityHandler) GetActivityByID(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get activity from the database
|
// Get activity from the database
|
||||||
activity, err := models.GetActivityByID(c.Request.Context(), models.FromULID(id))
|
activity, err := models.GetActivityByID(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving activity: "+err.Error())
|
utils.InternalErrorResponse(c, "Error retrieving activity: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -207,7 +207,7 @@ func (h *ActivityHandler) DeleteActivity(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete activity from the database
|
// Delete activity from the database
|
||||||
err = models.DeleteActivity(c.Request.Context(), models.FromULID(id))
|
err = models.DeleteActivity(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error deleting activity: "+err.Error())
|
utils.InternalErrorResponse(c, "Error deleting activity: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -238,7 +238,7 @@ func convertCreateActivityDTOToModel(dto dto.ActivityCreateDto) models.ActivityC
|
|||||||
func convertUpdateActivityDTOToModel(dto dto.ActivityUpdateDto) models.ActivityUpdate {
|
func convertUpdateActivityDTOToModel(dto dto.ActivityUpdateDto) models.ActivityUpdate {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
id, _ := ulid.Parse(dto.ID)
|
||||||
update := models.ActivityUpdate{
|
update := models.ActivityUpdate{
|
||||||
ID: models.FromULID(id),
|
ID: id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.Name != nil {
|
if dto.Name != nil {
|
||||||
|
@ -72,7 +72,7 @@ func (h *CompanyHandler) GetCompanyByID(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get company from the database
|
// Get company from the database
|
||||||
company, err := models.GetCompanyByID(c.Request.Context(), models.FromULID(id))
|
company, err := models.GetCompanyByID(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving company: "+err.Error())
|
utils.InternalErrorResponse(c, "Error retrieving company: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -207,7 +207,7 @@ func (h *CompanyHandler) DeleteCompany(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete company from the database
|
// Delete company from the database
|
||||||
err = models.DeleteCompany(c.Request.Context(), models.FromULID(id))
|
err = models.DeleteCompany(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error deleting company: "+err.Error())
|
utils.InternalErrorResponse(c, "Error deleting company: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -236,7 +236,7 @@ func convertCreateCompanyDTOToModel(dto dto.CompanyCreateDto) models.CompanyCrea
|
|||||||
func convertUpdateCompanyDTOToModel(dto dto.CompanyUpdateDto) models.CompanyUpdate {
|
func convertUpdateCompanyDTOToModel(dto dto.CompanyUpdateDto) models.CompanyUpdate {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
id, _ := ulid.Parse(dto.ID)
|
||||||
update := models.CompanyUpdate{
|
update := models.CompanyUpdate{
|
||||||
ID: models.FromULID(id),
|
ID: id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.Name != nil {
|
if dto.Name != nil {
|
||||||
|
@ -73,7 +73,7 @@ func (h *CustomerHandler) GetCustomerByID(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get customer from the database
|
// Get customer from the database
|
||||||
customer, err := models.GetCustomerByID(c.Request.Context(), models.FromULID(id))
|
customer, err := models.GetCustomerByID(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving customer: "+err.Error())
|
utils.InternalErrorResponse(c, "Error retrieving customer: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -247,7 +247,7 @@ func (h *CustomerHandler) DeleteCustomer(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete customer from the database
|
// Delete customer from the database
|
||||||
err = models.DeleteCustomer(c.Request.Context(), models.FromULID(id))
|
err = models.DeleteCustomer(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error deleting customer: "+err.Error())
|
utils.InternalErrorResponse(c, "Error deleting customer: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -278,7 +278,7 @@ func convertCreateCustomerDTOToModel(dto dto.CustomerCreateDto) models.CustomerC
|
|||||||
func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) models.CustomerUpdate {
|
func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) models.CustomerUpdate {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
id, _ := ulid.Parse(dto.ID)
|
||||||
update := models.CustomerUpdate{
|
update := models.CustomerUpdate{
|
||||||
ID: models.FromULID(id),
|
ID: id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.Name != nil {
|
if dto.Name != nil {
|
||||||
|
@ -102,7 +102,7 @@ func (h *ProjectHandler) GetProjectByID(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get project from the database
|
// Get project from the database
|
||||||
project, err := models.GetProjectByID(c.Request.Context(), models.FromULID(id))
|
project, err := models.GetProjectByID(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving project: "+err.Error())
|
utils.InternalErrorResponse(c, "Error retrieving project: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -297,7 +297,7 @@ func (h *ProjectHandler) DeleteProject(c *gin.Context) {
|
|||||||
|
|
||||||
func convertProjectToDTO(project *models.Project) dto.ProjectDto {
|
func convertProjectToDTO(project *models.Project) dto.ProjectDto {
|
||||||
customerID := 0
|
customerID := 0
|
||||||
if project.CustomerID.Compare(models.ULIDWrapper{}) != 0 {
|
if project.CustomerID.Compare(ulid.ULID{}) != 0 {
|
||||||
// This is a simplification, adjust as needed
|
// This is a simplification, adjust as needed
|
||||||
customerID = int(project.CustomerID.Time())
|
customerID = int(project.CustomerID.Time())
|
||||||
}
|
}
|
||||||
@ -320,14 +320,14 @@ func convertCreateProjectDTOToModel(dto dto.ProjectCreateDto) (models.ProjectCre
|
|||||||
|
|
||||||
return models.ProjectCreate{
|
return models.ProjectCreate{
|
||||||
Name: dto.Name,
|
Name: dto.Name,
|
||||||
CustomerID: models.FromULID(customerID),
|
CustomerID: customerID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpdate, error) {
|
func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpdate, error) {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
id, _ := ulid.Parse(dto.ID)
|
||||||
update := models.ProjectUpdate{
|
update := models.ProjectUpdate{
|
||||||
ID: models.FromULID(id),
|
ID: id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.Name != nil {
|
if dto.Name != nil {
|
||||||
@ -340,8 +340,7 @@ func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpd
|
|||||||
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)
|
||||||
}
|
}
|
||||||
wrappedID := models.FromULID(customerID)
|
update.CustomerID = &customerID
|
||||||
update.CustomerID = &wrappedID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return update, nil
|
return update, nil
|
||||||
|
@ -75,7 +75,7 @@ func (h *TimeEntryHandler) GetTimeEntryByID(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get time entry from the database
|
// Get time entry from the database
|
||||||
timeEntry, err := models.GetTimeEntryByID(c.Request.Context(), models.FromULID(id))
|
timeEntry, err := models.GetTimeEntryByID(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving time entry: "+err.Error())
|
utils.InternalErrorResponse(c, "Error retrieving time entry: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -116,7 +116,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByUserID(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get time entries from the database
|
// Get time entries from the database
|
||||||
timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), models.FromULID(userID))
|
timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -152,7 +152,7 @@ func (h *TimeEntryHandler) GetMyTimeEntries(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get time entries from the database
|
// Get time entries from the database
|
||||||
timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), models.FromULID(userID))
|
timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -191,7 +191,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByProjectID(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get time entries from the database
|
// Get time entries from the database
|
||||||
timeEntries, err := models.GetTimeEntriesByProjectID(c.Request.Context(), models.FromULID(projectID))
|
timeEntries, err := models.GetTimeEntriesByProjectID(c.Request.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -390,7 +390,7 @@ func (h *TimeEntryHandler) DeleteTimeEntry(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete time entry from the database
|
// Delete time entry from the database
|
||||||
err = models.DeleteTimeEntry(c.Request.Context(), models.FromULID(id))
|
err = models.DeleteTimeEntry(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error deleting time entry: "+err.Error())
|
utils.InternalErrorResponse(c, "Error deleting time entry: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -434,9 +434,9 @@ func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEn
|
|||||||
}
|
}
|
||||||
|
|
||||||
return models.TimeEntryCreate{
|
return models.TimeEntryCreate{
|
||||||
UserID: models.FromULID(userID),
|
UserID: userID,
|
||||||
ProjectID: models.FromULID(projectID),
|
ProjectID: projectID,
|
||||||
ActivityID: models.FromULID(activityID),
|
ActivityID: activityID,
|
||||||
Start: dto.Start,
|
Start: dto.Start,
|
||||||
End: dto.End,
|
End: dto.End,
|
||||||
Description: dto.Description,
|
Description: dto.Description,
|
||||||
@ -447,7 +447,7 @@ func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEn
|
|||||||
func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEntryUpdate, error) {
|
func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEntryUpdate, error) {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
id, _ := ulid.Parse(dto.ID)
|
||||||
update := models.TimeEntryUpdate{
|
update := models.TimeEntryUpdate{
|
||||||
ID: models.FromULID(id),
|
ID: id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.UserID != nil {
|
if dto.UserID != nil {
|
||||||
@ -455,8 +455,7 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err)
|
return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err)
|
||||||
}
|
}
|
||||||
wrappedID := models.FromULID(userID)
|
update.UserID = &userID
|
||||||
update.UserID = &wrappedID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.ProjectID != nil {
|
if dto.ProjectID != nil {
|
||||||
@ -464,8 +463,7 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err)
|
return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err)
|
||||||
}
|
}
|
||||||
wrappedProjectID := models.FromULID(projectID)
|
update.ProjectID = &projectID
|
||||||
update.ProjectID = &wrappedProjectID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.ActivityID != nil {
|
if dto.ActivityID != nil {
|
||||||
@ -473,8 +471,7 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err)
|
return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err)
|
||||||
}
|
}
|
||||||
wrappedActivityID := models.FromULID(activityID)
|
update.ActivityID = &activityID
|
||||||
update.ActivityID = &wrappedActivityID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.Start != nil {
|
if dto.Start != nil {
|
||||||
|
@ -73,7 +73,7 @@ func (h *UserHandler) GetUserByID(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user from the database
|
// Get user from the database
|
||||||
user, err := models.GetUserByID(c.Request.Context(), models.FromULID(id))
|
user, err := models.GetUserByID(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error())
|
utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -208,7 +208,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete user from the database
|
// Delete user from the database
|
||||||
err = models.DeleteUser(c.Request.Context(), models.FromULID(id))
|
err = models.DeleteUser(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error deleting user: "+err.Error())
|
utils.InternalErrorResponse(c, "Error deleting user: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -328,7 +328,7 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user from the database
|
// Get user from the database
|
||||||
user, err := models.GetUserByID(c.Request.Context(), models.FromULID(userID))
|
user, err := models.GetUserByID(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error())
|
utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -354,14 +354,14 @@ func convertUserToDTO(user *models.User) dto.UserDto {
|
|||||||
UpdatedAt: user.UpdatedAt,
|
UpdatedAt: user.UpdatedAt,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
CompanyID: user.CompanyID.String(),
|
CompanyID: int(user.CompanyID.Time()), // This is a simplification, adjust as needed
|
||||||
HourlyRate: user.HourlyRate,
|
HourlyRate: user.HourlyRate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate {
|
func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate {
|
||||||
// Convert CompanyID from int to ULID (this is a simplification, adjust as needed)
|
// Convert CompanyID from int to ULID (this is a simplification, adjust as needed)
|
||||||
companyID, _ := models.ULIDWrapperFromString(dto.CompanyID)
|
companyID, _ := ulid.Parse("01H1VECTJQXS1RVWJT6QG3QJCJ")
|
||||||
|
|
||||||
return models.UserCreate{
|
return models.UserCreate{
|
||||||
Email: dto.Email,
|
Email: dto.Email,
|
||||||
@ -375,7 +375,7 @@ func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate {
|
|||||||
func convertUpdateDTOToModel(dto dto.UserUpdateDto) models.UserUpdate {
|
func convertUpdateDTOToModel(dto dto.UserUpdateDto) models.UserUpdate {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
id, _ := ulid.Parse(dto.ID)
|
||||||
update := models.UserUpdate{
|
update := models.UserUpdate{
|
||||||
ID: models.FromULID(id),
|
ID: id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.Email != nil {
|
if dto.Email != nil {
|
||||||
@ -392,7 +392,7 @@ func convertUpdateDTOToModel(dto dto.UserUpdateDto) models.UserUpdate {
|
|||||||
|
|
||||||
if dto.CompanyID != nil {
|
if dto.CompanyID != nil {
|
||||||
// Convert CompanyID from int to ULID (this is a simplification, adjust as needed)
|
// Convert CompanyID from int to ULID (this is a simplification, adjust as needed)
|
||||||
companyID, _ := models.ULIDWrapperFromString(*dto.CompanyID)
|
companyID, _ := ulid.Parse("01H1VECTJQXS1RVWJT6QG3QJCJ")
|
||||||
update.CompanyID = &companyID
|
update.CompanyID = &companyID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ func SetupRouter(r *gin.Engine) {
|
|||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
protected := api.Group("")
|
protected := api.Group("")
|
||||||
//protected.Use(middleware.AuthMiddleware())
|
protected.Use(middleware.AuthMiddleware())
|
||||||
{
|
{
|
||||||
// Auth routes (protected)
|
// Auth routes (protected)
|
||||||
protectedAuth := protected.Group("/auth")
|
protectedAuth := protected.Group("/auth")
|
||||||
|
@ -1,86 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config represents the application configuration
|
|
||||||
type Config struct {
|
|
||||||
Database models.DatabaseConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadConfig loads configuration from environment variables and .env file
|
|
||||||
func LoadConfig() (*Config, error) {
|
|
||||||
// Try loading .env file, but don't fail if it doesn't exist
|
|
||||||
_ = godotenv.Load()
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
Database: models.DefaultDatabaseConfig(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load database configuration
|
|
||||||
if err := loadDatabaseConfig(cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load database config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadDatabaseConfig loads database configuration from environment
|
|
||||||
func loadDatabaseConfig(cfg *Config) error {
|
|
||||||
// Required fields
|
|
||||||
cfg.Database.Host = getEnv("DB_HOST", cfg.Database.Host)
|
|
||||||
cfg.Database.User = getEnv("DB_USER", cfg.Database.User)
|
|
||||||
cfg.Database.Password = getEnv("DB_PASSWORD", cfg.Database.Password)
|
|
||||||
cfg.Database.DBName = getEnv("DB_NAME", cfg.Database.DBName)
|
|
||||||
cfg.Database.SSLMode = getEnv("DB_SSLMODE", cfg.Database.SSLMode)
|
|
||||||
|
|
||||||
// Optional fields with parsing
|
|
||||||
if port := getEnv("DB_PORT", ""); port != "" {
|
|
||||||
portInt, err := strconv.Atoi(port)
|
|
||||||
if err != nil || portInt <= 0 {
|
|
||||||
return errors.New("invalid DB_PORT value")
|
|
||||||
}
|
|
||||||
cfg.Database.Port = portInt
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log level based on environment
|
|
||||||
if os.Getenv("ENVIRONMENT") == "production" {
|
|
||||||
cfg.Database.LogLevel = logger.Error
|
|
||||||
} else {
|
|
||||||
cfg.Database.LogLevel = logger.Info
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if cfg.Database.Host == "" || cfg.Database.User == "" ||
|
|
||||||
cfg.Database.Password == "" || cfg.Database.DBName == "" {
|
|
||||||
return errors.New("missing required database configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getEnv gets an environment variable with fallback
|
|
||||||
func getEnv(key, fallback string) string {
|
|
||||||
if value, exists := os.LookupEnv(key); exists {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// MustLoadConfig loads configuration or panics on failure
|
|
||||||
func MustLoadConfig() *Config {
|
|
||||||
cfg, err := LoadConfig()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
@ -11,7 +11,7 @@ type UserDto struct {
|
|||||||
LastEditorID string `json:"lastEditorID"`
|
LastEditorID string `json:"lastEditorID"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
CompanyID string `json:"companyId"`
|
CompanyID int `json:"companyId"`
|
||||||
HourlyRate float64 `json:"hourlyRate"`
|
HourlyRate float64 `json:"hourlyRate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ type UserCreateDto struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
CompanyID string `json:"companyId"`
|
CompanyID int `json:"companyId"`
|
||||||
HourlyRate float64 `json:"hourlyRate"`
|
HourlyRate float64 `json:"hourlyRate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +31,6 @@ type UserUpdateDto struct {
|
|||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
Role *string `json:"role"`
|
Role *string `json:"role"`
|
||||||
CompanyID *string `json:"companyId"`
|
CompanyID *int `json:"companyId"`
|
||||||
HourlyRate *float64 `json:"hourlyRate"`
|
HourlyRate *float64 `json:"hourlyRate"`
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,7 +22,7 @@ func (Activity) TableName() string {
|
|||||||
|
|
||||||
// ActivityUpdate contains the updatable fields of an Activity
|
// ActivityUpdate contains the updatable fields of an Activity
|
||||||
type ActivityUpdate struct {
|
type ActivityUpdate struct {
|
||||||
ID ULIDWrapper `gorm:"-"` // Use "-" to indicate that this field should be ignored
|
ID ulid.ULID `gorm:"-"` // Use "-" to indicate that this field should be ignored
|
||||||
Name *string `gorm:"column:name"`
|
Name *string `gorm:"column:name"`
|
||||||
BillingRate *float64 `gorm:"column:billing_rate"`
|
BillingRate *float64 `gorm:"column:billing_rate"`
|
||||||
}
|
}
|
||||||
@ -33,7 +34,7 @@ type ActivityCreate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetActivityByID finds an Activity by its ID
|
// GetActivityByID finds an Activity by its ID
|
||||||
func GetActivityByID(ctx context.Context, id ULIDWrapper) (*Activity, error) {
|
func GetActivityByID(ctx context.Context, id ulid.ULID) (*Activity, error) {
|
||||||
var activity Activity
|
var activity Activity
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&activity)
|
result := GetEngine(ctx).Where("id = ?", id).First(&activity)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@ -89,7 +90,7 @@ 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 ULIDWrapper) error {
|
func DeleteActivity(ctx context.Context, id ulid.ULID) error {
|
||||||
result := GetEngine(ctx).Delete(&Activity{}, id)
|
result := GetEngine(ctx).Delete(&Activity{}, id)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"runtime/debug"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/oklog/ulid/v2"
|
||||||
@ -11,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type EntityBase struct {
|
type EntityBase struct {
|
||||||
ID ULIDWrapper `gorm:"type:char(26);primaryKey"`
|
ID ulid.ULID `gorm:"type:uuid;primaryKey"`
|
||||||
CreatedAt time.Time `gorm:"index"`
|
CreatedAt time.Time `gorm:"index"`
|
||||||
UpdatedAt time.Time `gorm:"index"`
|
UpdatedAt time.Time `gorm:"index"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
@ -19,15 +17,10 @@ type EntityBase struct {
|
|||||||
|
|
||||||
// BeforeCreate is called by GORM before creating a record
|
// BeforeCreate is called by GORM before creating a record
|
||||||
func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error {
|
func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error {
|
||||||
fmt.Println("BeforeCreate called")
|
if eb.ID.Compare(ulid.ULID{}) == 0 { // If ID is empty
|
||||||
stack := debug.Stack()
|
|
||||||
fmt.Println("foo's stack:", string(stack))
|
|
||||||
if eb.ID.Compare(ULIDWrapper{}) == 0 { // If ID is empty
|
|
||||||
// Generate a new ULID
|
// Generate a new ULID
|
||||||
entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0)
|
entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0)
|
||||||
newID := ulid.MustNew(ulid.Timestamp(time.Now()), entropy)
|
eb.ID = ulid.MustNew(ulid.Timestamp(time.Now()), entropy)
|
||||||
eb.ID = ULIDWrapper{ULID: newID}
|
|
||||||
fmt.Println("Generated ID:", eb.ID)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -25,12 +26,12 @@ type CompanyCreate struct {
|
|||||||
|
|
||||||
// CompanyUpdate contains the updatable fields of a company
|
// CompanyUpdate contains the updatable fields of a company
|
||||||
type CompanyUpdate struct {
|
type CompanyUpdate struct {
|
||||||
ID ULIDWrapper `gorm:"-"` // Exclude from updates
|
ID ulid.ULID `gorm:"-"` // Exclude from updates
|
||||||
Name *string `gorm:"column:name"`
|
Name *string `gorm:"column:name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCompanyByID finds a company by its ID
|
// GetCompanyByID finds a company by its ID
|
||||||
func GetCompanyByID(ctx context.Context, id ULIDWrapper) (*Company, error) {
|
func GetCompanyByID(ctx context.Context, id ulid.ULID) (*Company, error) {
|
||||||
var company Company
|
var company Company
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&company)
|
result := GetEngine(ctx).Where("id = ?", id).First(&company)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@ -94,7 +95,7 @@ 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 ULIDWrapper) error {
|
func DeleteCompany(ctx context.Context, id ulid.ULID) error {
|
||||||
result := GetEngine(ctx).Delete(&Company{}, id)
|
result := GetEngine(ctx).Delete(&Company{}, id)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -27,13 +28,13 @@ type CustomerCreate struct {
|
|||||||
|
|
||||||
// CustomerUpdate contains the updatable fields of a customer
|
// CustomerUpdate contains the updatable fields of a customer
|
||||||
type CustomerUpdate struct {
|
type CustomerUpdate struct {
|
||||||
ID ULIDWrapper `gorm:"-"` // Exclude from updates
|
ID ulid.ULID `gorm:"-"` // Exclude from updates
|
||||||
Name *string `gorm:"column:name"`
|
Name *string `gorm:"column:name"`
|
||||||
CompanyID *int `gorm:"column:company_id"`
|
CompanyID *int `gorm:"column:company_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCustomerByID finds a customer by its ID
|
// GetCustomerByID finds a customer by its ID
|
||||||
func GetCustomerByID(ctx context.Context, id ULIDWrapper) (*Customer, error) {
|
func GetCustomerByID(ctx context.Context, id ulid.ULID) (*Customer, error) {
|
||||||
var customer Customer
|
var customer Customer
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&customer)
|
result := GetEngine(ctx).Where("id = ?", id).First(&customer)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@ -89,7 +90,7 @@ 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 ULIDWrapper) error {
|
func DeleteCustomer(ctx context.Context, id ulid.ULID) error {
|
||||||
result := GetEngine(ctx).Delete(&Customer{}, id)
|
result := GetEngine(ctx).Delete(&Customer{}, id)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
@ -114,6 +114,64 @@ func MigrateDB() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeedDB seeds the database with initial data if needed
|
||||||
|
func SeedDB(ctx context.Context) error {
|
||||||
|
if defaultDB == nil {
|
||||||
|
return errors.New("database not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Checking if database seeding is needed...")
|
||||||
|
|
||||||
|
// Check if we need to seed (e.g., no companies exist)
|
||||||
|
var count int64
|
||||||
|
if err := defaultDB.Model(&Company{}).Count(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("error checking if seeding is needed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If data already exists, skip seeding
|
||||||
|
if count > 0 {
|
||||||
|
log.Println("Database already contains data, skipping seeding")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Seeding database with initial data...")
|
||||||
|
|
||||||
|
// Start a transaction for all seed operations
|
||||||
|
return defaultDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Create a default company
|
||||||
|
defaultCompany := Company{
|
||||||
|
Name: "Default Company",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&defaultCompany).Error; err != nil {
|
||||||
|
return fmt.Errorf("error creating default company: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an admin user
|
||||||
|
adminUser := User{
|
||||||
|
Email: "admin@example.com",
|
||||||
|
Role: RoleAdmin,
|
||||||
|
CompanyID: defaultCompany.ID,
|
||||||
|
HourlyRate: 100.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash a default password
|
||||||
|
pwData, err := HashPassword("Admin@123456")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error hashing password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser.Salt = pwData.Salt
|
||||||
|
adminUser.Hash = pwData.Hash
|
||||||
|
|
||||||
|
if err := tx.Create(&adminUser).Error; err != nil {
|
||||||
|
return fmt.Errorf("error creating admin user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Database seeding completed successfully")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetEngine returns the DB instance, possibly with context
|
// GetEngine returns the DB instance, possibly with context
|
||||||
func GetEngine(ctx context.Context) *gorm.DB {
|
func GetEngine(ctx context.Context) *gorm.DB {
|
||||||
if defaultDB == nil {
|
if defaultDB == nil {
|
||||||
|
@ -13,7 +13,7 @@ import (
|
|||||||
type Project struct {
|
type Project struct {
|
||||||
EntityBase
|
EntityBase
|
||||||
Name string `gorm:"column:name;not null"`
|
Name string `gorm:"column:name;not null"`
|
||||||
CustomerID ULIDWrapper `gorm:"column:customer_id;type:char(26);not null"`
|
CustomerID ulid.ULID `gorm:"column:customer_id;type:uuid;not null"`
|
||||||
|
|
||||||
// Relationships (for Eager Loading)
|
// Relationships (for Eager Loading)
|
||||||
Customer *Customer `gorm:"foreignKey:CustomerID"`
|
Customer *Customer `gorm:"foreignKey:CustomerID"`
|
||||||
@ -27,14 +27,14 @@ func (Project) TableName() string {
|
|||||||
// ProjectCreate contains the fields for creating a new project
|
// ProjectCreate contains the fields for creating a new project
|
||||||
type ProjectCreate struct {
|
type ProjectCreate struct {
|
||||||
Name string
|
Name string
|
||||||
CustomerID ULIDWrapper
|
CustomerID ulid.ULID
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectUpdate contains the updatable fields of a project
|
// ProjectUpdate contains the updatable fields of a project
|
||||||
type ProjectUpdate struct {
|
type ProjectUpdate struct {
|
||||||
ID ULIDWrapper `gorm:"-"` // Exclude from updates
|
ID ulid.ULID `gorm:"-"` // Exclude from updates
|
||||||
Name *string `gorm:"column:name"`
|
Name *string `gorm:"column:name"`
|
||||||
CustomerID *ULIDWrapper `gorm:"column:customer_id"`
|
CustomerID *ulid.ULID `gorm:"column:customer_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the Create struct contains valid data
|
// Validate checks if the Create struct contains valid data
|
||||||
@ -43,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.Compare(ULIDWrapper{}) == 0 {
|
if pc.CustomerID.Compare(ulid.ULID{}) == 0 {
|
||||||
return errors.New("customerID cannot be empty")
|
return errors.New("customerID cannot be empty")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -58,7 +58,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 ULIDWrapper) (*Project, error) {
|
func GetProjectByID(ctx context.Context, id ulid.ULID) (*Project, error) {
|
||||||
var project Project
|
var project Project
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&project)
|
result := GetEngine(ctx).Where("id = ?", id).First(&project)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
|
@ -6,15 +6,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TimeEntry represents a time entry in the system
|
// TimeEntry represents a time entry in the system
|
||||||
type TimeEntry struct {
|
type TimeEntry struct {
|
||||||
EntityBase
|
EntityBase
|
||||||
UserID ULIDWrapper `gorm:"column:user_id;type:char(26);not null;index"`
|
UserID ulid.ULID `gorm:"column:user_id;type:uuid;not null;index"`
|
||||||
ProjectID ULIDWrapper `gorm:"column:project_id;type:char(26);not null;index"`
|
ProjectID ulid.ULID `gorm:"column:project_id;type:uuid;not null;index"`
|
||||||
ActivityID ULIDWrapper `gorm:"column:activity_id;type:char(26);not null;index"`
|
ActivityID ulid.ULID `gorm:"column:activity_id;type:uuid;not null;index"`
|
||||||
Start time.Time `gorm:"column:start;not null"`
|
Start time.Time `gorm:"column:start;not null"`
|
||||||
End time.Time `gorm:"column:end;not null"`
|
End time.Time `gorm:"column:end;not null"`
|
||||||
Description string `gorm:"column:description"`
|
Description string `gorm:"column:description"`
|
||||||
@ -33,9 +34,9 @@ func (TimeEntry) TableName() string {
|
|||||||
|
|
||||||
// TimeEntryCreate contains the fields for creating a new time entry
|
// TimeEntryCreate contains the fields for creating a new time entry
|
||||||
type TimeEntryCreate struct {
|
type TimeEntryCreate struct {
|
||||||
UserID ULIDWrapper
|
UserID ulid.ULID
|
||||||
ProjectID ULIDWrapper
|
ProjectID ulid.ULID
|
||||||
ActivityID ULIDWrapper
|
ActivityID ulid.ULID
|
||||||
Start time.Time
|
Start time.Time
|
||||||
End time.Time
|
End time.Time
|
||||||
Description string
|
Description string
|
||||||
@ -44,10 +45,10 @@ type TimeEntryCreate struct {
|
|||||||
|
|
||||||
// TimeEntryUpdate contains the updatable fields of a time entry
|
// TimeEntryUpdate contains the updatable fields of a time entry
|
||||||
type TimeEntryUpdate struct {
|
type TimeEntryUpdate struct {
|
||||||
ID ULIDWrapper `gorm:"-"` // Exclude from updates
|
ID ulid.ULID `gorm:"-"` // Exclude from updates
|
||||||
UserID *ULIDWrapper `gorm:"column:user_id"`
|
UserID *ulid.ULID `gorm:"column:user_id"`
|
||||||
ProjectID *ULIDWrapper `gorm:"column:project_id"`
|
ProjectID *ulid.ULID `gorm:"column:project_id"`
|
||||||
ActivityID *ULIDWrapper `gorm:"column:activity_id"`
|
ActivityID *ulid.ULID `gorm:"column:activity_id"`
|
||||||
Start *time.Time `gorm:"column:start"`
|
Start *time.Time `gorm:"column:start"`
|
||||||
End *time.Time `gorm:"column:end"`
|
End *time.Time `gorm:"column:end"`
|
||||||
Description *string `gorm:"column:description"`
|
Description *string `gorm:"column:description"`
|
||||||
@ -57,13 +58,13 @@ type TimeEntryUpdate struct {
|
|||||||
// Validate checks if the Create struct contains valid data
|
// Validate checks if the Create struct contains valid data
|
||||||
func (tc *TimeEntryCreate) Validate() error {
|
func (tc *TimeEntryCreate) Validate() error {
|
||||||
// Check for empty IDs
|
// Check for empty IDs
|
||||||
if tc.UserID.Compare(ULIDWrapper{}) == 0 {
|
if tc.UserID.Compare(ulid.ULID{}) == 0 {
|
||||||
return errors.New("userID cannot be empty")
|
return errors.New("userID cannot be empty")
|
||||||
}
|
}
|
||||||
if tc.ProjectID.Compare(ULIDWrapper{}) == 0 {
|
if tc.ProjectID.Compare(ulid.ULID{}) == 0 {
|
||||||
return errors.New("projectID cannot be empty")
|
return errors.New("projectID cannot be empty")
|
||||||
}
|
}
|
||||||
if tc.ActivityID.Compare(ULIDWrapper{}) == 0 {
|
if tc.ActivityID.Compare(ulid.ULID{}) == 0 {
|
||||||
return errors.New("activityID cannot be empty")
|
return errors.New("activityID cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +103,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 ULIDWrapper) (*TimeEntry, error) {
|
func GetTimeEntryByID(ctx context.Context, id ulid.ULID) (*TimeEntry, error) {
|
||||||
var timeEntry TimeEntry
|
var timeEntry TimeEntry
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
|
result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@ -115,7 +116,7 @@ func GetTimeEntryByID(ctx context.Context, id ULIDWrapper) (*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 ULIDWrapper) (*TimeEntry, error) {
|
func GetTimeEntryWithRelations(ctx context.Context, id ulid.ULID) (*TimeEntry, error) {
|
||||||
var timeEntry TimeEntry
|
var timeEntry TimeEntry
|
||||||
result := GetEngine(ctx).
|
result := GetEngine(ctx).
|
||||||
Preload("User").
|
Preload("User").
|
||||||
@ -145,7 +146,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 ULIDWrapper) ([]TimeEntry, error) {
|
func GetTimeEntriesByUserID(ctx context.Context, userID ulid.ULID) ([]TimeEntry, error) {
|
||||||
var timeEntries []TimeEntry
|
var timeEntries []TimeEntry
|
||||||
result := 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 {
|
||||||
@ -155,7 +156,7 @@ func GetTimeEntriesByUserID(ctx context.Context, userID ULIDWrapper) ([]TimeEntr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTimeEntriesByProjectID returns all time entries of a project
|
// GetTimeEntriesByProjectID returns all time entries of a project
|
||||||
func GetTimeEntriesByProjectID(ctx context.Context, projectID ULIDWrapper) ([]TimeEntry, error) {
|
func GetTimeEntriesByProjectID(ctx context.Context, projectID ulid.ULID) ([]TimeEntry, error) {
|
||||||
var timeEntries []TimeEntry
|
var timeEntries []TimeEntry
|
||||||
result := 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 {
|
||||||
@ -180,7 +181,7 @@ func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]Tim
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SumBillableHoursByProject calculates the billable hours per project
|
// SumBillableHoursByProject calculates the billable hours per project
|
||||||
func SumBillableHoursByProject(ctx context.Context, projectID ULIDWrapper) (float64, error) {
|
func SumBillableHoursByProject(ctx context.Context, projectID ulid.ULID) (float64, error) {
|
||||||
type Result struct {
|
type Result struct {
|
||||||
TotalHours float64
|
TotalHours float64
|
||||||
}
|
}
|
||||||
@ -246,7 +247,7 @@ func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateReferences checks if all referenced entities exist
|
// validateReferences checks if all referenced entities exist
|
||||||
func validateReferences(tx *gorm.DB, userID, projectID, activityID ULIDWrapper) error {
|
func validateReferences(tx *gorm.DB, userID, projectID, activityID ulid.ULID) error {
|
||||||
// Check user
|
// Check user
|
||||||
var userCount int64
|
var userCount int64
|
||||||
if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil {
|
if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil {
|
||||||
@ -350,7 +351,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 ULIDWrapper) error {
|
func DeleteTimeEntry(ctx context.Context, id ulid.ULID) error {
|
||||||
result := 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)
|
||||||
|
@ -1,75 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql/driver"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ULIDWrapper wraps ulid.ULID to allow method definitions
|
|
||||||
type ULIDWrapper struct {
|
|
||||||
ulid.ULID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare implements the same comparison method as ulid.ULID
|
|
||||||
func (u ULIDWrapper) Compare(other ULIDWrapper) int {
|
|
||||||
return u.ULID.Compare(other.ULID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FromULID creates a ULIDWrapper from a ulid.ULID
|
|
||||||
func FromULID(id ulid.ULID) ULIDWrapper {
|
|
||||||
return ULIDWrapper{ULID: id}
|
|
||||||
}
|
|
||||||
|
|
||||||
// From String creates a ULIDWrapper from a string
|
|
||||||
func ULIDWrapperFromString(id string) (ULIDWrapper, error) {
|
|
||||||
parsed, err := ulid.Parse(id)
|
|
||||||
if err != nil {
|
|
||||||
return ULIDWrapper{}, fmt.Errorf("failed to parse ULID string: %w", err)
|
|
||||||
}
|
|
||||||
return FromULID(parsed), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToULID converts a ULIDWrapper to a ulid.ULID
|
|
||||||
func (u ULIDWrapper) ToULID() ulid.ULID {
|
|
||||||
return u.ULID
|
|
||||||
}
|
|
||||||
|
|
||||||
// GormValue implements the gorm.Valuer interface for ULIDWrapper
|
|
||||||
func (u ULIDWrapper) GormValue(ctx context.Context, db *gorm.DB) clause.Expr {
|
|
||||||
return clause.Expr{
|
|
||||||
SQL: "?",
|
|
||||||
Vars: []any{u.String()},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan implements the Scanner interface for ULIDWrapper
|
|
||||||
func (u *ULIDWrapper) Scan(src any) error {
|
|
||||||
switch v := src.(type) {
|
|
||||||
case string:
|
|
||||||
parsed, err := ulid.Parse(v)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse ULID string: %w", err)
|
|
||||||
}
|
|
||||||
u.ULID = parsed
|
|
||||||
return nil
|
|
||||||
case []byte:
|
|
||||||
parsed, err := ulid.Parse(string(v))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse ULID bytes: %w", err)
|
|
||||||
}
|
|
||||||
u.ULID = parsed
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("cannot scan %T into ULIDWrapper", src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Value implements the driver.Valuer interface for ULIDWrapper
|
|
||||||
func (u ULIDWrapper) Value() (driver.Value, error) {
|
|
||||||
return u.String(), nil
|
|
||||||
}
|
|
@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -39,7 +40,7 @@ type User struct {
|
|||||||
Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Base64-encoded Salt
|
Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Base64-encoded Salt
|
||||||
Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash
|
Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash
|
||||||
Role string `gorm:"column:role;not null;default:'user'"`
|
Role string `gorm:"column:role;not null;default:'user'"`
|
||||||
CompanyID ULIDWrapper `gorm:"column:company_id;type:char(26);not null;index"`
|
CompanyID ulid.ULID `gorm:"column:company_id;type:uuid;not null;index"`
|
||||||
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
||||||
|
|
||||||
// Relationship for Eager Loading
|
// Relationship for Eager Loading
|
||||||
@ -56,17 +57,17 @@ type UserCreate struct {
|
|||||||
Email string
|
Email string
|
||||||
Password string
|
Password string
|
||||||
Role string
|
Role string
|
||||||
CompanyID ULIDWrapper
|
CompanyID ulid.ULID
|
||||||
HourlyRate float64
|
HourlyRate float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserUpdate contains the updatable fields of a user
|
// UserUpdate contains the updatable fields of a user
|
||||||
type UserUpdate struct {
|
type UserUpdate struct {
|
||||||
ID ULIDWrapper `gorm:"-"` // Exclude from updates
|
ID ulid.ULID `gorm:"-"` // Exclude from updates
|
||||||
Email *string `gorm:"column:email"`
|
Email *string `gorm:"column:email"`
|
||||||
Password *string `gorm:"-"` // Not stored directly in DB
|
Password *string `gorm:"-"` // Not stored directly in DB
|
||||||
Role *string `gorm:"column:role"`
|
Role *string `gorm:"column:role"`
|
||||||
CompanyID *ULIDWrapper `gorm:"column:company_id"`
|
CompanyID *ulid.ULID `gorm:"column:company_id"`
|
||||||
HourlyRate *float64 `gorm:"column:hourly_rate"`
|
HourlyRate *float64 `gorm:"column:hourly_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,7 +202,7 @@ func (uc *UserCreate) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if uc.CompanyID.Compare(ULIDWrapper{}) == 0 {
|
if uc.CompanyID.Compare(ulid.ULID{}) == 0 {
|
||||||
return errors.New("companyID cannot be empty")
|
return errors.New("companyID cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -287,7 +288,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 ULIDWrapper) (*User, error) {
|
func GetUserByID(ctx context.Context, id ulid.ULID) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&user)
|
result := GetEngine(ctx).Where("id = ?", id).First(&user)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@ -313,7 +314,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 ULIDWrapper) (*User, error) {
|
func GetUserWithCompany(ctx context.Context, id ulid.ULID) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
result := 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 {
|
||||||
@ -336,7 +337,7 @@ func GetAllUsers(ctx context.Context) ([]User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUsersByCompanyID returns all users of a company
|
// GetUsersByCompanyID returns all users of a company
|
||||||
func GetUsersByCompanyID(ctx context.Context, companyID ULIDWrapper) ([]User, error) {
|
func GetUsersByCompanyID(ctx context.Context, companyID ulid.ULID) ([]User, error) {
|
||||||
var users []User
|
var users []User
|
||||||
result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&users)
|
result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&users)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@ -497,7 +498,7 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser deletes a user by their ID
|
// DeleteUser deletes a user by their ID
|
||||||
func DeleteUser(ctx context.Context, id ULIDWrapper) error {
|
func DeleteUser(ctx context.Context, id ulid.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
|
||||||
|
|
||||||
|
@ -11,16 +11,6 @@ services:
|
|||||||
POSTGRES_DB: timetracker
|
POSTGRES_DB: timetracker
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- db_data:/var/lib/postgresql/data
|
||||||
pgadmin:
|
|
||||||
image: dpage/pgadmin4
|
|
||||||
container_name: pgadmin
|
|
||||||
environment:
|
|
||||||
PGADMIN_DEFAULT_EMAIL: admin@example.com
|
|
||||||
PGADMIN_DEFAULT_PASSWORD: admin
|
|
||||||
ports:
|
|
||||||
- "8081:80"
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Note:** This document describes a *conceptual* architecture and is not a final, binding requirement.
|
**Note:** This document describes a *conceptual* architecture and is not a final, binding requirement.
|
||||||
|
|
||||||
The backend is written in Go using idiomatic Go patterns. While initially following Clean Architecture and DDD principles, we've adapted to a more pragmatic approach that better fits Go's conventions and reduces boilerplate code.
|
The backend is written in Go and follows the principles of **Clean Architecture** and **Domain-Driven Design (DDD)**.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
```
|
```
|
||||||
|
Loading…
x
Reference in New Issue
Block a user