diff --git a/Dockerfile b/Dockerfile index e4e3eba..4f39df4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,10 +11,12 @@ RUN go mod download # Copy source code and environment files COPY *.go ./ +COPY internal/ ./internal/ +COPY cmd/ ./cmd/ COPY .env* ./ # Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api # Build stage for React frontend FROM node:18-alpine AS frontend-builder diff --git a/api b/api new file mode 100755 index 0000000..97b6785 Binary files /dev/null and b/api differ diff --git a/auth.go b/auth.go deleted file mode 100644 index 1951bd0..0000000 --- a/auth.go +++ /dev/null @@ -1,237 +0,0 @@ -package main - -import ( - "crypto/rand" - "database/sql" - "fmt" - "net/http" - "os" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" - "golang.org/x/crypto/bcrypt" -) - -var jwtSecret []byte - -// InitJWT initializes JWT secret (legacy function) -func InitJWT() { - secret := os.Getenv("JWT_SECRET") - if secret == "" { - // Generate a random secret for development - secret = generateRandomSecret() - Logger.Println("Warning: Using generated JWT secret. Set JWT_SECRET environment variable for production.") - } - jwtSecret = []byte(secret) -} - -// InitJWTWithConfig initializes JWT secret with configuration -func InitJWTWithConfig(config *Config) { - jwtSecret = []byte(config.JWTSecret) -} - -// generateRandomSecret generates a random secret for JWT -func generateRandomSecret() string { - b := make([]byte, 32) - rand.Read(b) - return fmt.Sprintf("%x", b) -} - -// hashPassword hashes a password using bcrypt -func hashPassword(password string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) - return string(bytes), err -} - -// checkPasswordHash compares a password with its hash -func checkPasswordHash(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil -} - -// generateToken generates a JWT token for a user -func generateToken(userID int, username string) (string, error) { - claims := jwt.MapClaims{ - "user_id": userID, - "username": username, - "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days - "iat": time.Now().Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(jwtSecret) -} - -// validateToken validates a JWT token and returns the claims -func validateToken(tokenString string) (jwt.MapClaims, error) { - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return jwtSecret, nil - }) - - if err != nil { - return nil, err - } - - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - return claims, nil - } - - return nil, fmt.Errorf("invalid token") -} - -// AuthMiddleware validates JWT token and sets user context -func AuthMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) - c.Abort() - return - } - - // Extract token from "Bearer " - tokenParts := strings.Split(authHeader, " ") - if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"}) - c.Abort() - return - } - - tokenString := tokenParts[1] - claims, err := validateToken(tokenString) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) - c.Abort() - return - } - - // Set user information in context - c.Set("user_id", int(claims["user_id"].(float64))) - c.Set("username", claims["username"]) - c.Next() - } -} - -// RegisterHandler handles user registration -func RegisterHandler(c *gin.Context) { - var req RegisterRequest - if err := c.ShouldBindJSON(&req); err != nil { - RecordAuthAttempt("register", "bad_request") - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Check if username already exists - var existingUser User - err := db.QueryRow("SELECT id FROM users WHERE username = $1 OR email = $2", req.Username, req.Email).Scan(&existingUser.ID) - if err != sql.ErrNoRows { - RecordAuthAttempt("register", "conflict") - c.JSON(http.StatusConflict, gin.H{"error": "Username or email already exists"}) - return - } - - // Hash password - hashedPassword, err := hashPassword(req.Password) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) - return - } - - // Create user - var user User - err = db.QueryRow( - "INSERT INTO users (username, email, password) VALUES ($1, $2, $3) RETURNING id, username, email, created_at, updated_at", - req.Username, req.Email, hashedPassword, - ).Scan(&user.ID, &user.Username, &user.Email, &user.CreatedAt, &user.UpdatedAt) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) - return - } - - // Generate token - token, err := generateToken(user.ID, user.Username) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) - return - } - - RecordAuthAttempt("register", "success") - c.JSON(http.StatusCreated, AuthResponse{ - Token: token, - User: user, - }) -} - -// LoginHandler handles user login -func LoginHandler(c *gin.Context) { - var req LoginRequest - if err := c.ShouldBindJSON(&req); err != nil { - RecordAuthAttempt("login", "bad_request") - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Find user - var user User - err := db.QueryRow( - "SELECT id, username, email, password, created_at, updated_at FROM users WHERE username = $1", - req.Username, - ).Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt) - - if err == sql.ErrNoRows { - RecordAuthAttempt("login", "user_not_found") - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials 1"}) - return - } else if err != nil { - RecordAuthAttempt("login", "database_error") - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) - return - } - - // Check password - if !checkPasswordHash(req.Password, user.Password) { - RecordAuthAttempt("login", "invalid_password") - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials 2"}) - return - } - - // Generate token - token, err := generateToken(user.ID, user.Username) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) - return - } - - // Clear password from response - user.Password = "" - - RecordAuthAttempt("login", "success") - c.JSON(http.StatusOK, AuthResponse{ - Token: token, - User: user, - }) -} - -// GetCurrentUserHandler returns the current authenticated user -func GetCurrentUserHandler(c *gin.Context) { - userID := c.GetInt("user_id") - - var user User - err := db.QueryRow( - "SELECT id, username, email, created_at, updated_at FROM users WHERE id = $1", - userID, - ).Scan(&user.ID, &user.Username, &user.Email, &user.CreatedAt, &user.UpdatedAt) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"}) - return - } - - c.JSON(http.StatusOK, user) -} diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..30e7da6 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "counter/internal/delivery/http" + "counter/internal/delivery/http/handlers" + "counter/internal/infrastructure/config" + "counter/internal/infrastructure/database/postgres" + "counter/internal/infrastructure/logging" + "counter/internal/infrastructure/metrics" + "counter/internal/infrastructure/security" + "counter/internal/usecase/auth" + "counter/internal/usecase/counter" + + "github.com/sirupsen/logrus" +) + +func main() { + // 1. Load configuration + cfg := config.LoadConfig() + + // 2. Initialize logger + logger, err := logging.InitLogger(cfg) + if err != nil { + logrus.Fatal("Failed to initialize logger:", err) + } + + // 3. Initialize database + dbConn, err := postgres.NewConnection(cfg, logger) + if err != nil { + logger.WithError(err).Fatal("Failed to initialize database") + } + defer dbConn.Close() + + // 4. Initialize repositories (infrastructure implementations) + userRepo := postgres.NewUserRepository(dbConn.GetDB()) + counterRepo := postgres.NewCounterRepository(dbConn.GetDB()) + + // 5. Initialize services (infrastructure) + passwordService := security.NewPasswordService() + jwtService := security.NewJWTService(cfg.JWTSecret) + metricsService := metrics.NewPrometheusMetricsService() + + // 6. Initialize use cases (business logic) + authService := auth.NewAuthService(userRepo, passwordService, jwtService) + counterService := counter.NewCounterService(counterRepo) + + // 7. Initialize handlers (delivery) + authHandler := handlers.NewAuthHandler(authService) + counterHandler := handlers.NewCounterHandler(counterService) + + // 8. Initialize router + router := http.NewRouter(authHandler, counterHandler, cfg, logger, metricsService, jwtService) + router.SetupRoutes() + + // 9. Start metrics server + metricsService.StartMetricsServer(cfg.MetricsPort) + + // 10. Start HTTP server + port := cfg.Port + + logger.WithFields(logrus.Fields{ + "port": port, + "metrics_port": cfg.MetricsPort, + "log_dir": cfg.LogDir, + "log_volume": cfg.LogVolume, + }).Info("🚀 Starting Counter Application Server") + + logger.WithFields(logrus.Fields{ + "health_url": "http://localhost:" + port + "/health", + "api_url": "http://localhost:" + port + "/api/v1", + "frontend_url": "http://localhost:" + port + "/", + "metrics_url": "http://localhost:" + cfg.MetricsPort + "/metrics", + }).Info("✅ Server is ready and accepting connections") + + logger.Fatal(router.GetRouter().Run(":" + port)) +} diff --git a/counters.go b/counters.go deleted file mode 100644 index c3afc46..0000000 --- a/counters.go +++ /dev/null @@ -1,362 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/gin-gonic/gin" -) - -// CreateCounterHandler creates a new counter -func CreateCounterHandler(c *gin.Context) { - var req CreateCounterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := c.GetInt("user_id") - - var counter Counter - err := db.QueryRow( - "INSERT INTO counters (user_id, name, description) VALUES ($1, $2, $3) RETURNING id, user_id, name, description, created_at, updated_at", - userID, req.Name, req.Description, - ).Scan(&counter.ID, &counter.UserID, &counter.Name, &counter.Description, &counter.CreatedAt, &counter.UpdatedAt) - - if err != nil { - RecordDBOperation("insert", "counters") - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter"}) - return - } - - RecordDBOperation("insert", "counters") - - c.JSON(http.StatusCreated, counter) -} - -// GetCountersHandler retrieves all counters for the authenticated user -func GetCountersHandler(c *gin.Context) { - userID := c.GetInt("user_id") - search := c.Query("search") - - query := ` - SELECT c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at, - COALESCE(SUM(ce.value), 0) as total_value, - COALESCE(SUM(CASE WHEN ce.date = CURRENT_DATE THEN ce.value ELSE 0 END), 0) as today_value, - COALESCE(SUM(CASE WHEN ce.date >= CURRENT_DATE - INTERVAL '7 days' THEN ce.value ELSE 0 END), 0) as week_value, - COALESCE(SUM(CASE WHEN ce.date >= DATE_TRUNC('month', CURRENT_DATE) THEN ce.value ELSE 0 END), 0) as month_value, - COUNT(ce.id) as entry_count - FROM counters c - LEFT JOIN counter_entries ce ON c.id = ce.counter_id - WHERE c.user_id = $1 - ` - - args := []interface{}{userID} - if search != "" { - query += " AND (c.name ILIKE $2 OR c.description ILIKE $2)" - args = append(args, "%"+search+"%") - } - - query += " GROUP BY c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at ORDER BY c.updated_at DESC" - - rows, err := db.Query(query, args...) - if err != nil { - RecordDBOperation("select", "counters") - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counters"}) - return - } - defer rows.Close() - - RecordDBOperation("select", "counters") - - counters := []CounterWithStats{} // Initialize as empty slice, not nil - for rows.Next() { - var counter CounterWithStats - err := rows.Scan( - &counter.ID, &counter.UserID, &counter.Name, &counter.Description, - &counter.CreatedAt, &counter.UpdatedAt, &counter.TotalValue, - &counter.TodayValue, &counter.WeekValue, &counter.MonthValue, - &counter.EntryCount, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan counter"}) - return - } - counters = append(counters, counter) - } - - c.JSON(http.StatusOK, counters) -} - -// GetCounterHandler retrieves a specific counter by ID -func GetCounterHandler(c *gin.Context) { - userID := c.GetInt("user_id") - counterID, err := strconv.Atoi(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) - return - } - - var counter CounterWithStats - err = db.QueryRow(` - SELECT c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at, - COALESCE(SUM(ce.value), 0) as total_value, - COALESCE(SUM(CASE WHEN ce.date = CURRENT_DATE THEN ce.value ELSE 0 END), 0) as today_value, - COALESCE(SUM(CASE WHEN ce.date >= CURRENT_DATE - INTERVAL '7 days' THEN ce.value ELSE 0 END), 0) as week_value, - COALESCE(SUM(CASE WHEN ce.date >= DATE_TRUNC('month', CURRENT_DATE) THEN ce.value ELSE 0 END), 0) as month_value, - COUNT(ce.id) as entry_count - FROM counters c - LEFT JOIN counter_entries ce ON c.id = ce.counter_id - WHERE c.id = $1 AND c.user_id = $2 - GROUP BY c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at - `, counterID, userID).Scan( - &counter.ID, &counter.UserID, &counter.Name, &counter.Description, - &counter.CreatedAt, &counter.UpdatedAt, &counter.TotalValue, - &counter.TodayValue, &counter.WeekValue, &counter.MonthValue, - &counter.EntryCount, - ) - - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) - return - } else if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counter"}) - return - } - - c.JSON(http.StatusOK, counter) -} - -// UpdateCounterHandler updates a counter -func UpdateCounterHandler(c *gin.Context) { - userID := c.GetInt("user_id") - counterID, err := strconv.Atoi(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) - return - } - - var req UpdateCounterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - var counter Counter - err = db.QueryRow( - "UPDATE counters SET name = $1, description = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND user_id = $4 RETURNING id, user_id, name, description, created_at, updated_at", - req.Name, req.Description, counterID, userID, - ).Scan(&counter.ID, &counter.UserID, &counter.Name, &counter.Description, &counter.CreatedAt, &counter.UpdatedAt) - - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) - return - } else if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update counter"}) - return - } - - c.JSON(http.StatusOK, counter) -} - -// DeleteCounterHandler deletes a counter -func DeleteCounterHandler(c *gin.Context) { - userID := c.GetInt("user_id") - counterID, err := strconv.Atoi(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) - return - } - - result, err := db.Exec("DELETE FROM counters WHERE id = $1 AND user_id = $2", counterID, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete counter"}) - return - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check deletion"}) - return - } - - if rowsAffected == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Counter deleted successfully"}) -} - -// IncrementCounterHandler increments/decrements a counter -func IncrementCounterHandler(c *gin.Context) { - userID := c.GetInt("user_id") - counterID, err := strconv.Atoi(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) - return - } - - var req IncrementRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Verify counter belongs to user - var exists bool - err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)", counterID, userID).Scan(&exists) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) - return - } - if !exists { - c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) - return - } - - // Insert counter entry - var entry CounterEntry - err = db.QueryRow( - "INSERT INTO counter_entries (counter_id, value, date) VALUES ($1, $2, CURRENT_DATE) RETURNING id, counter_id, value, date, created_at", - counterID, req.Value, - ).Scan(&entry.ID, &entry.CounterID, &entry.Value, &entry.Date, &entry.CreatedAt) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter entry"}) - return - } - - // Update counter's updated_at timestamp - _, err = db.Exec("UPDATE counters SET updated_at = CURRENT_TIMESTAMP WHERE id = $1", counterID) - if err != nil { - // Log error but don't fail the request - fmt.Printf("Warning: Failed to update counter timestamp: %v\n", err) - } - - c.JSON(http.StatusCreated, entry) -} - -// GetCounterEntriesHandler retrieves entries for a specific counter -func GetCounterEntriesHandler(c *gin.Context) { - userID := c.GetInt("user_id") - counterID, err := strconv.Atoi(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) - return - } - - // Verify counter belongs to user - var exists bool - err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)", counterID, userID).Scan(&exists) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) - return - } - if !exists { - c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) - return - } - - // Parse date range parameters - startDate := c.Query("start_date") - endDate := c.Query("end_date") - - query := ` - SELECT id, counter_id, value, date, created_at - FROM counter_entries - WHERE counter_id = $1 - ` - args := []interface{}{counterID} - - if startDate != "" { - query += " AND date >= $2" - args = append(args, startDate) - if endDate != "" { - query += " AND date <= $3" - args = append(args, endDate) - } - } else if endDate != "" { - query += " AND date <= $2" - args = append(args, endDate) - } - - query += " ORDER BY date DESC, created_at DESC" - - rows, err := db.Query(query, args...) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counter entries"}) - return - } - defer rows.Close() - - var entries []CounterEntry - for rows.Next() { - var entry CounterEntry - err := rows.Scan(&entry.ID, &entry.CounterID, &entry.Value, &entry.Date, &entry.CreatedAt) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan entry"}) - return - } - entries = append(entries, entry) - } - - c.JSON(http.StatusOK, entries) -} - -// GetCounterStatsHandler retrieves statistics for a counter -func GetCounterStatsHandler(c *gin.Context) { - userID := c.GetInt("user_id") - counterID, err := strconv.Atoi(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) - return - } - - // Verify counter belongs to user - var exists bool - err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)", counterID, userID).Scan(&exists) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) - return - } - if !exists { - c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) - return - } - - // Get daily statistics for the last 30 days - rows, err := db.Query(` - SELECT date, SUM(value) as daily_total - FROM counter_entries - WHERE counter_id = $1 AND date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY date - ORDER BY date DESC - `, counterID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"}) - return - } - defer rows.Close() - - type DailyStat struct { - Date time.Time `json:"date"` - Total int `json:"total"` - } - - var stats []DailyStat - for rows.Next() { - var stat DailyStat - err := rows.Scan(&stat.Date, &stat.Total) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan statistic"}) - return - } - stats = append(stats, stat) - } - - c.JSON(http.StatusOK, gin.H{"daily_stats": stats}) -} diff --git a/go.mod b/go.mod index 3276500..6ca0847 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/prometheus/client_golang v1.21.0 + github.com/sirupsen/logrus v1.9.3 golang.org/x/crypto v0.31.0 ) @@ -38,7 +39,6 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect diff --git a/internal/delivery/http/handlers/auth_handler.go b/internal/delivery/http/handlers/auth_handler.go new file mode 100644 index 0000000..20cfc4f --- /dev/null +++ b/internal/delivery/http/handlers/auth_handler.go @@ -0,0 +1,79 @@ +package handlers + +import ( + "net/http" + + "counter/internal/domain/entities" + "counter/internal/usecase/auth" + + "github.com/gin-gonic/gin" +) + +// AuthHandler handles authentication HTTP requests +type AuthHandler struct { + authService *auth.AuthService +} + +// NewAuthHandler creates a new authentication handler +func NewAuthHandler(authService *auth.AuthService) *AuthHandler { + return &AuthHandler{ + authService: authService, + } +} + +// Register handles user registration +func (h *AuthHandler) Register(c *gin.Context) { + var req auth.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.authService.Register(c.Request.Context(), &req) + if err != nil { + switch err { + case entities.ErrUserAlreadyExists: + c.JSON(http.StatusConflict, gin.H{"error": "Username or email already exists"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) + } + return + } + + c.JSON(http.StatusCreated, response) +} + +// Login handles user login +func (h *AuthHandler) Login(c *gin.Context) { + var req auth.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.authService.Login(c.Request.Context(), &req) + if err != nil { + switch err { + case entities.ErrInvalidCredentials: + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Authentication failed"}) + } + return + } + + c.JSON(http.StatusOK, response) +} + +// GetMe returns the current authenticated user +func (h *AuthHandler) GetMe(c *gin.Context) { + userID := c.GetInt("user_id") + + user, err := h.authService.GetCurrentUser(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"}) + return + } + + c.JSON(http.StatusOK, user) +} diff --git a/internal/delivery/http/handlers/counter_handler.go b/internal/delivery/http/handlers/counter_handler.go new file mode 100644 index 0000000..d3a8d80 --- /dev/null +++ b/internal/delivery/http/handlers/counter_handler.go @@ -0,0 +1,228 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "counter/internal/domain/entities" + "counter/internal/usecase/counter" + + "github.com/gin-gonic/gin" +) + +// CounterHandler handles counter HTTP requests +type CounterHandler struct { + counterService *counter.CounterService +} + +// NewCounterHandler creates a new counter handler +func NewCounterHandler(counterService *counter.CounterService) *CounterHandler { + return &CounterHandler{ + counterService: counterService, + } +} + +// Create handles counter creation +func (h *CounterHandler) Create(c *gin.Context) { + userID := c.GetInt("user_id") + + var req counter.CreateCounterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + counter, err := h.counterService.Create(c.Request.Context(), userID, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter"}) + return + } + + c.JSON(http.StatusCreated, counter) +} + +// Get handles retrieving a specific counter +func (h *CounterHandler) Get(c *gin.Context) { + userID := c.GetInt("user_id") + counterID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) + return + } + + counter, err := h.counterService.Get(c.Request.Context(), counterID, userID) + if err != nil { + switch err { + case entities.ErrCounterNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counter"}) + } + return + } + + c.JSON(http.StatusOK, counter) +} + +// List handles retrieving all counters for a user +func (h *CounterHandler) List(c *gin.Context) { + userID := c.GetInt("user_id") + search := c.Query("search") + + counters, err := h.counterService.List(c.Request.Context(), userID, search) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counters"}) + return + } + + c.JSON(http.StatusOK, counters) +} + +// Update handles counter updates +func (h *CounterHandler) Update(c *gin.Context) { + userID := c.GetInt("user_id") + counterID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) + return + } + + var req counter.UpdateCounterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + counter, err := h.counterService.Update(c.Request.Context(), counterID, userID, &req) + if err != nil { + switch err { + case entities.ErrCounterNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update counter"}) + } + return + } + + c.JSON(http.StatusOK, counter) +} + +// Delete handles counter deletion +func (h *CounterHandler) Delete(c *gin.Context) { + userID := c.GetInt("user_id") + counterID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) + return + } + + err = h.counterService.Delete(c.Request.Context(), counterID, userID) + if err != nil { + switch err { + case entities.ErrCounterNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete counter"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Counter deleted successfully"}) +} + +// Increment handles counter increment/decrement +func (h *CounterHandler) Increment(c *gin.Context) { + userID := c.GetInt("user_id") + counterID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) + return + } + + var req counter.IncrementRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + entry, err := h.counterService.Increment(c.Request.Context(), counterID, userID, &req) + if err != nil { + switch err { + case entities.ErrCounterNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter entry"}) + } + return + } + + c.JSON(http.StatusCreated, entry) +} + +// GetEntries handles retrieving counter entries +func (h *CounterHandler) GetEntries(c *gin.Context) { + userID := c.GetInt("user_id") + counterID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) + return + } + + // Parse date range parameters + var startDate, endDate *time.Time + if startDateStr := c.Query("start_date"); startDateStr != "" { + if parsed, err := time.Parse("2006-01-02", startDateStr); err == nil { + startDate = &parsed + } + } + if endDateStr := c.Query("end_date"); endDateStr != "" { + if parsed, err := time.Parse("2006-01-02", endDateStr); err == nil { + endDate = &parsed + } + } + + entries, err := h.counterService.GetEntries(c.Request.Context(), counterID, userID, startDate, endDate) + if err != nil { + switch err { + case entities.ErrCounterNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counter entries"}) + } + return + } + + c.JSON(http.StatusOK, entries) +} + +// GetStats handles retrieving counter statistics +func (h *CounterHandler) GetStats(c *gin.Context) { + userID := c.GetInt("user_id") + counterID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"}) + return + } + + // Parse days parameter + days := 30 // default + if daysStr := c.Query("days"); daysStr != "" { + if parsed, err := strconv.Atoi(daysStr); err == nil && parsed > 0 { + days = parsed + } + } + + stats, err := h.counterService.GetStats(c.Request.Context(), counterID, userID, days) + if err != nil { + switch err { + case entities.ErrCounterNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"daily_stats": stats}) +} diff --git a/internal/delivery/http/middleware/auth.go b/internal/delivery/http/middleware/auth.go new file mode 100644 index 0000000..4fca656 --- /dev/null +++ b/internal/delivery/http/middleware/auth.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "net/http" + "strings" + + "counter/internal/infrastructure/security" + + "github.com/gin-gonic/gin" +) + +// AuthMiddleware validates JWT token and sets user context +func AuthMiddleware(jwtService security.JWTService) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) + c.Abort() + return + } + + // Extract token from "Bearer " + tokenParts := strings.Split(authHeader, " ") + if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"}) + c.Abort() + return + } + + tokenString := tokenParts[1] + claims, err := jwtService.ValidateToken(tokenString) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + c.Abort() + return + } + + // Set user information in context + userID, ok := claims["user_id"].(float64) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"}) + c.Abort() + return + } + + username, ok := claims["username"].(string) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"}) + c.Abort() + return + } + + c.Set("user_id", int(userID)) + c.Set("username", username) + c.Next() + } +} diff --git a/internal/delivery/http/middleware/logging.go b/internal/delivery/http/middleware/logging.go new file mode 100644 index 0000000..c167286 --- /dev/null +++ b/internal/delivery/http/middleware/logging.go @@ -0,0 +1,43 @@ +package middleware + +import ( + "time" + + "counter/internal/infrastructure/config" + "counter/internal/infrastructure/logging" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +// LoggingMiddleware creates a Gin middleware for HTTP request logging +func LoggingMiddleware(logger logging.Logger, cfg *config.Config) gin.HandlerFunc { + return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + // Create structured log entry with default fields + entry := logger.WithFields(logrus.Fields{ + "service": "counter-app", + "environment": string(cfg.Environment), + "version": "1.0.0", + "method": param.Method, + "path": param.Path, + "status": param.StatusCode, + "latency": param.Latency.String(), + "client_ip": param.ClientIP, + "user_agent": param.Request.UserAgent(), + "timestamp": param.TimeStamp.Format(time.RFC3339), + }) + + // Set log level based on status code + switch { + case param.StatusCode >= 500: + entry.Error("HTTP Request") + case param.StatusCode >= 400: + entry.Warn("HTTP Request") + default: + entry.Info("HTTP Request") + } + + // Return empty string since we're handling logging ourselves + return "" + }) +} diff --git a/internal/delivery/http/middleware/metrics.go b/internal/delivery/http/middleware/metrics.go new file mode 100644 index 0000000..ae12fca --- /dev/null +++ b/internal/delivery/http/middleware/metrics.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "time" + + "counter/internal/infrastructure/metrics" + + "github.com/gin-gonic/gin" +) + +// MetricsMiddleware creates a Gin middleware for HTTP metrics +func MetricsMiddleware(metricsService metrics.MetricsService) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + + c.Next() + + duration := time.Since(start) + metricsService.RecordHTTPRequest(c.Request.Method, c.FullPath(), c.Writer.Status(), duration) + } +} diff --git a/internal/delivery/http/router.go b/internal/delivery/http/router.go new file mode 100644 index 0000000..9f058e2 --- /dev/null +++ b/internal/delivery/http/router.go @@ -0,0 +1,107 @@ +package http + +import ( + "counter/internal/delivery/http/handlers" + "counter/internal/delivery/http/middleware" + "counter/internal/infrastructure/config" + "counter/internal/infrastructure/logging" + "counter/internal/infrastructure/metrics" + "counter/internal/infrastructure/security" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// Router sets up all HTTP routes and middleware +type Router struct { + router *gin.Engine + authHandler *handlers.AuthHandler + counterHandler *handlers.CounterHandler + config *config.Config + logger logging.Logger + metricsService metrics.MetricsService + jwtService security.JWTService +} + +// NewRouter creates a new router with all dependencies +func NewRouter( + authHandler *handlers.AuthHandler, + counterHandler *handlers.CounterHandler, + cfg *config.Config, + logger logging.Logger, + metricsService metrics.MetricsService, + jwtService security.JWTService, +) *Router { + // Set Gin mode + gin.SetMode(cfg.GinMode) + + router := gin.Default() + + return &Router{ + router: router, + authHandler: authHandler, + counterHandler: counterHandler, + config: cfg, + logger: logger, + metricsService: metricsService, + jwtService: jwtService, + } +} + +// SetupRoutes configures all routes and middleware +func (r *Router) SetupRoutes() { + // Configure CORS + corsConfig := cors.DefaultConfig() + corsConfig.AllowOrigins = []string{"http://localhost:3000", "http://localhost:5173"} // React dev servers + corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} + corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"} + corsConfig.AllowCredentials = true + r.router.Use(cors.New(corsConfig)) + + // Add middleware + r.router.Use(middleware.MetricsMiddleware(r.metricsService)) + r.router.Use(middleware.LoggingMiddleware(r.logger, r.config)) + + // Health check endpoint + r.router.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // API routes + api := r.router.Group("/api/v1") + { + // Authentication routes + authGroup := api.Group("/auth") + { + authGroup.POST("/register", r.authHandler.Register) + authGroup.POST("/login", r.authHandler.Login) + authGroup.GET("/me", middleware.AuthMiddleware(r.jwtService), r.authHandler.GetMe) + } + + // Counter routes (protected) + counterGroup := api.Group("/counters") + counterGroup.Use(middleware.AuthMiddleware(r.jwtService)) + { + counterGroup.POST("", r.counterHandler.Create) + counterGroup.GET("", r.counterHandler.List) + counterGroup.GET("/:id", r.counterHandler.Get) + counterGroup.PUT("/:id", r.counterHandler.Update) + counterGroup.DELETE("/:id", r.counterHandler.Delete) + counterGroup.POST("/:id/increment", r.counterHandler.Increment) + counterGroup.GET("/:id/entries", r.counterHandler.GetEntries) + counterGroup.GET("/:id/stats", r.counterHandler.GetStats) + } + } + + // Serve static files (React app) + r.router.Static("/static", "./frontend/build/static") + r.router.StaticFile("/", "./frontend/build/index.html") + r.router.NoRoute(func(c *gin.Context) { + c.File("./frontend/build/index.html") + }) +} + +// GetRouter returns the configured Gin router +func (r *Router) GetRouter() *gin.Engine { + return r.router +} diff --git a/internal/domain/entities/counter.go b/internal/domain/entities/counter.go new file mode 100644 index 0000000..b92fb68 --- /dev/null +++ b/internal/domain/entities/counter.go @@ -0,0 +1,60 @@ +package entities + +import "time" + +// Counter represents a counter entity +type Counter struct { + ID int `json:"id" db:"id"` + UserID int `json:"user_id" db:"user_id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// CounterEntry represents a single increment/decrement entry +type CounterEntry struct { + ID int `json:"id" db:"id"` + CounterID int `json:"counter_id" db:"counter_id"` + Value int `json:"value" db:"value"` // +1 for increment, -1 for decrement + Date time.Time `json:"date" db:"date"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// CounterWithStats represents a counter with aggregated statistics +type CounterWithStats struct { + Counter + TotalValue int `json:"total_value"` + TodayValue int `json:"today_value"` + WeekValue int `json:"week_value"` + MonthValue int `json:"month_value"` + EntryCount int `json:"entry_count"` +} + +// DailyStat represents daily statistics for a counter +type DailyStat struct { + Date time.Time `json:"date"` + Total int `json:"total"` +} + +// Validate validates counter data +func (c *Counter) Validate() error { + if c.Name == "" { + return ErrInvalidCounterName + } + if c.UserID <= 0 { + return ErrInvalidUserID + } + return nil +} + +// Validate validates counter entry data +func (ce *CounterEntry) Validate() error { + if ce.CounterID <= 0 { + return ErrInvalidCounterID + } + if ce.Value == 0 { + return ErrInvalidEntryValue + } + return nil +} diff --git a/internal/domain/entities/errors.go b/internal/domain/entities/errors.go new file mode 100644 index 0000000..e65d058 --- /dev/null +++ b/internal/domain/entities/errors.go @@ -0,0 +1,19 @@ +package entities + +import "errors" + +// Domain errors +var ( + ErrInvalidUsername = errors.New("username is required") + ErrInvalidEmail = errors.New("email is required") + ErrInvalidPassword = errors.New("password is required") + ErrInvalidCounterName = errors.New("counter name is required") + ErrInvalidUserID = errors.New("invalid user ID") + ErrInvalidCounterID = errors.New("invalid counter ID") + ErrInvalidEntryValue = errors.New("entry value cannot be zero") + ErrUserNotFound = errors.New("user not found") + ErrCounterNotFound = errors.New("counter not found") + ErrCounterEntryNotFound = errors.New("counter entry not found") + ErrUserAlreadyExists = errors.New("user already exists") + ErrInvalidCredentials = errors.New("invalid credentials") +) diff --git a/internal/domain/entities/user.go b/internal/domain/entities/user.go new file mode 100644 index 0000000..8e46ca2 --- /dev/null +++ b/internal/domain/entities/user.go @@ -0,0 +1,32 @@ +package entities + +import "time" + +// User represents a registered user +type User struct { + ID int `json:"id" db:"id"` + Username string `json:"username" db:"username"` + Email string `json:"email" db:"email"` + Password string `json:"-" db:"password"` // Hidden from JSON + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Validate validates user data +func (u *User) Validate() error { + if u.Username == "" { + return ErrInvalidUsername + } + if u.Email == "" { + return ErrInvalidEmail + } + if u.Password == "" { + return ErrInvalidPassword + } + return nil +} + +// ClearPassword removes password from user for safe serialization +func (u *User) ClearPassword() { + u.Password = "" +} diff --git a/internal/domain/repositories/counter_repository.go b/internal/domain/repositories/counter_repository.go new file mode 100644 index 0000000..403f8e4 --- /dev/null +++ b/internal/domain/repositories/counter_repository.go @@ -0,0 +1,20 @@ +package repositories + +import ( + "context" + "counter/internal/domain/entities" + "time" +) + +// CounterRepository defines the interface for counter data operations +type CounterRepository interface { + Create(ctx context.Context, counter *entities.Counter) error + FindByID(ctx context.Context, id, userID int) (*entities.CounterWithStats, error) + FindByUserID(ctx context.Context, userID int, search string) ([]*entities.CounterWithStats, error) + Update(ctx context.Context, counter *entities.Counter) error + Delete(ctx context.Context, id, userID int) error + AddEntry(ctx context.Context, entry *entities.CounterEntry) error + GetEntries(ctx context.Context, counterID, userID int, startDate, endDate *time.Time) ([]*entities.CounterEntry, error) + GetDailyStats(ctx context.Context, counterID, userID int, days int) ([]*entities.DailyStat, error) + Exists(ctx context.Context, id, userID int) (bool, error) +} diff --git a/internal/domain/repositories/user_repository.go b/internal/domain/repositories/user_repository.go new file mode 100644 index 0000000..cd3a3a0 --- /dev/null +++ b/internal/domain/repositories/user_repository.go @@ -0,0 +1,16 @@ +package repositories + +import ( + "context" + "counter/internal/domain/entities" +) + +// UserRepository defines the interface for user data operations +type UserRepository interface { + Create(ctx context.Context, user *entities.User) error + FindByID(ctx context.Context, id int) (*entities.User, error) + FindByUsername(ctx context.Context, username string) (*entities.User, error) + FindByEmail(ctx context.Context, email string) (*entities.User, error) + Update(ctx context.Context, user *entities.User) error + Delete(ctx context.Context, id int) error +} diff --git a/config.go b/internal/infrastructure/config/config.go similarity index 98% rename from config.go rename to internal/infrastructure/config/config.go index 30eb7fb..0800c52 100644 --- a/config.go +++ b/internal/infrastructure/config/config.go @@ -1,4 +1,4 @@ -package main +package config import ( "fmt" @@ -63,7 +63,6 @@ func loadEnvironmentFiles() { // Get environment first (from system env or default) env := getEnvironmentFromSystem() - // Use standard log for early initialization (before logger is ready) log.Printf("🔍 Detected environment: %s", env) // Define file loading order (later files override earlier ones) diff --git a/database.go b/internal/infrastructure/database/postgres/connection.go similarity index 57% rename from database.go rename to internal/infrastructure/database/postgres/connection.go index 2c1f55d..abb390e 100644 --- a/database.go +++ b/internal/infrastructure/database/postgres/connection.go @@ -1,51 +1,56 @@ -package main +package postgres import ( "database/sql" "fmt" - "os" + + "counter/internal/infrastructure/config" + "counter/internal/infrastructure/logging" _ "github.com/lib/pq" ) -var db *sql.DB - -// InitDB initializes the database connection (legacy function) -func InitDB() error { - // Get database URL from environment variable - dbURL := os.Getenv("DATABASE_URL") - if dbURL == "" { - // Default for local development - dbURL = "postgres://postgres:password@localhost:5432/counter_db?sslmode=disable" - } - return initDBWithURL(dbURL) +// Connection manages the database connection +type Connection struct { + db *sql.DB } -// InitDBWithConfig initializes the database connection with configuration -func InitDBWithConfig(config *Config) error { - return initDBWithURL(config.DatabaseURL) -} - -// initDBWithURL initializes the database connection with a specific URL -func initDBWithURL(dbURL string) error { - - var err error - db, err = sql.Open("postgres", dbURL) +// NewConnection creates a new database connection +func NewConnection(cfg *config.Config, logger logging.Logger) (*Connection, error) { + db, err := sql.Open("postgres", cfg.DatabaseURL) if err != nil { - return fmt.Errorf("failed to open database: %w", err) + return nil, fmt.Errorf("failed to open database: %w", err) } // Test the connection if err = db.Ping(); err != nil { - return fmt.Errorf("failed to ping database: %w", err) + return nil, fmt.Errorf("failed to ping database: %w", err) } - Logger.Println("✅ Database connection established successfully") - return nil + logger.Info("✅ Database connection established successfully") + + conn := &Connection{db: db} + + // Create tables + if err := conn.CreateTables(); err != nil { + return nil, fmt.Errorf("failed to create tables: %w", err) + } + + return conn, nil +} + +// GetDB returns the database connection +func (c *Connection) GetDB() *sql.DB { + return c.db +} + +// Close closes the database connection +func (c *Connection) Close() error { + return c.db.Close() } // CreateTables creates the necessary database tables -func CreateTables() error { +func (c *Connection) CreateTables() error { queries := []string{ `CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, @@ -76,16 +81,10 @@ func CreateTables() error { } for _, query := range queries { - if _, err := db.Exec(query); err != nil { + if _, err := c.db.Exec(query); err != nil { return fmt.Errorf("failed to execute query: %w", err) } } - Logger.Println("✅ Database tables created successfully") return nil } - -// GetDB returns the database connection -func GetDB() *sql.DB { - return db -} diff --git a/internal/infrastructure/database/postgres/counter_repository.go b/internal/infrastructure/database/postgres/counter_repository.go new file mode 100644 index 0000000..6981ed5 --- /dev/null +++ b/internal/infrastructure/database/postgres/counter_repository.go @@ -0,0 +1,283 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "time" + + "counter/internal/domain/entities" + "counter/internal/domain/repositories" +) + +// CounterRepository implements the CounterRepository interface for PostgreSQL +type CounterRepository struct { + db *sql.DB +} + +// NewCounterRepository creates a new counter repository +func NewCounterRepository(db *sql.DB) repositories.CounterRepository { + return &CounterRepository{db: db} +} + +// Create creates a new counter +func (r *CounterRepository) Create(ctx context.Context, counter *entities.Counter) error { + query := ` + INSERT INTO counters (user_id, name, description) + VALUES ($1, $2, $3) + RETURNING id, created_at, updated_at + ` + + err := r.db.QueryRowContext(ctx, query, counter.UserID, counter.Name, counter.Description). + Scan(&counter.ID, &counter.CreatedAt, &counter.UpdatedAt) + + if err != nil { + return err + } + + return nil +} + +// FindByID finds a counter by ID with stats +func (r *CounterRepository) FindByID(ctx context.Context, id, userID int) (*entities.CounterWithStats, error) { + query := ` + SELECT c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at, + COALESCE(SUM(ce.value), 0) as total_value, + COALESCE(SUM(CASE WHEN ce.date = CURRENT_DATE THEN ce.value ELSE 0 END), 0) as today_value, + COALESCE(SUM(CASE WHEN ce.date >= CURRENT_DATE - INTERVAL '7 days' THEN ce.value ELSE 0 END), 0) as week_value, + COALESCE(SUM(CASE WHEN ce.date >= DATE_TRUNC('month', CURRENT_DATE) THEN ce.value ELSE 0 END), 0) as month_value, + COUNT(ce.id) as entry_count + FROM counters c + LEFT JOIN counter_entries ce ON c.id = ce.counter_id + WHERE c.id = $1 AND c.user_id = $2 + GROUP BY c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at + ` + + counter := &entities.CounterWithStats{} + err := r.db.QueryRowContext(ctx, query, id, userID). + Scan(&counter.ID, &counter.UserID, &counter.Name, &counter.Description, + &counter.CreatedAt, &counter.UpdatedAt, &counter.TotalValue, + &counter.TodayValue, &counter.WeekValue, &counter.MonthValue, &counter.EntryCount) + + if err == sql.ErrNoRows { + return nil, entities.ErrCounterNotFound + } + if err != nil { + return nil, err + } + + return counter, nil +} + +// FindByUserID finds all counters for a user with stats +func (r *CounterRepository) FindByUserID(ctx context.Context, userID int, search string) ([]*entities.CounterWithStats, error) { + query := ` + SELECT c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at, + COALESCE(SUM(ce.value), 0) as total_value, + COALESCE(SUM(CASE WHEN ce.date = CURRENT_DATE THEN ce.value ELSE 0 END), 0) as today_value, + COALESCE(SUM(CASE WHEN ce.date >= CURRENT_DATE - INTERVAL '7 days' THEN ce.value ELSE 0 END), 0) as week_value, + COALESCE(SUM(CASE WHEN ce.date >= DATE_TRUNC('month', CURRENT_DATE) THEN ce.value ELSE 0 END), 0) as month_value, + COUNT(ce.id) as entry_count + FROM counters c + LEFT JOIN counter_entries ce ON c.id = ce.counter_id + WHERE c.user_id = $1 + ` + + args := []interface{}{userID} + if search != "" { + query += " AND (c.name ILIKE $2 OR c.description ILIKE $2)" + args = append(args, "%"+search+"%") + } + + query += " GROUP BY c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at ORDER BY c.updated_at DESC" + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var counters []*entities.CounterWithStats + for rows.Next() { + counter := &entities.CounterWithStats{} + err := rows.Scan(&counter.ID, &counter.UserID, &counter.Name, &counter.Description, + &counter.CreatedAt, &counter.UpdatedAt, &counter.TotalValue, + &counter.TodayValue, &counter.WeekValue, &counter.MonthValue, &counter.EntryCount) + if err != nil { + return nil, err + } + counters = append(counters, counter) + } + + return counters, nil +} + +// Update updates a counter +func (r *CounterRepository) Update(ctx context.Context, counter *entities.Counter) error { + query := ` + UPDATE counters + SET name = $1, description = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND user_id = $4 + RETURNING updated_at + ` + + err := r.db.QueryRowContext(ctx, query, counter.Name, counter.Description, counter.ID, counter.UserID). + Scan(&counter.UpdatedAt) + + if err == sql.ErrNoRows { + return entities.ErrCounterNotFound + } + if err != nil { + return err + } + + return nil +} + +// Delete deletes a counter +func (r *CounterRepository) Delete(ctx context.Context, id, userID int) error { + query := `DELETE FROM counters WHERE id = $1 AND user_id = $2` + + result, err := r.db.ExecContext(ctx, query, id, userID) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return entities.ErrCounterNotFound + } + + return nil +} + +// AddEntry adds a new counter entry +func (r *CounterRepository) AddEntry(ctx context.Context, entry *entities.CounterEntry) error { + query := ` + INSERT INTO counter_entries (counter_id, value, date) + VALUES ($1, $2, $3) + RETURNING id, created_at + ` + + err := r.db.QueryRowContext(ctx, query, entry.CounterID, entry.Value, entry.Date). + Scan(&entry.ID, &entry.CreatedAt) + + if err != nil { + return err + } + + // Update counter's updated_at timestamp + _, err = r.db.ExecContext(ctx, "UPDATE counters SET updated_at = CURRENT_TIMESTAMP WHERE id = $1", entry.CounterID) + if err != nil { + // Log error but don't fail the request + // This could be improved with proper logging + } + + return nil +} + +// GetEntries retrieves entries for a specific counter +func (r *CounterRepository) GetEntries(ctx context.Context, counterID, userID int, startDate, endDate *time.Time) ([]*entities.CounterEntry, error) { + // First verify counter belongs to user + exists, err := r.Exists(ctx, counterID, userID) + if err != nil { + return nil, err + } + if !exists { + return nil, entities.ErrCounterNotFound + } + + query := ` + SELECT id, counter_id, value, date, created_at + FROM counter_entries + WHERE counter_id = $1 + ` + args := []interface{}{counterID} + + if startDate != nil { + query += fmt.Sprintf(" AND date >= $%d", len(args)+1) + args = append(args, *startDate) + if endDate != nil { + query += fmt.Sprintf(" AND date <= $%d", len(args)+1) + args = append(args, *endDate) + } + } else if endDate != nil { + query += fmt.Sprintf(" AND date <= $%d", len(args)+1) + args = append(args, *endDate) + } + + query += " ORDER BY date DESC, created_at DESC" + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []*entities.CounterEntry + for rows.Next() { + entry := &entities.CounterEntry{} + err := rows.Scan(&entry.ID, &entry.CounterID, &entry.Value, &entry.Date, &entry.CreatedAt) + if err != nil { + return nil, err + } + entries = append(entries, entry) + } + + return entries, nil +} + +// GetDailyStats retrieves daily statistics for a counter +func (r *CounterRepository) GetDailyStats(ctx context.Context, counterID, userID int, days int) ([]*entities.DailyStat, error) { + // First verify counter belongs to user + exists, err := r.Exists(ctx, counterID, userID) + if err != nil { + return nil, err + } + if !exists { + return nil, entities.ErrCounterNotFound + } + + query := ` + SELECT date, SUM(value) as daily_total + FROM counter_entries + WHERE counter_id = $1 AND date >= CURRENT_DATE - INTERVAL '%d days' + GROUP BY date + ORDER BY date DESC + ` + + rows, err := r.db.QueryContext(ctx, fmt.Sprintf(query, days), counterID) + if err != nil { + return nil, err + } + defer rows.Close() + + var stats []*entities.DailyStat + for rows.Next() { + stat := &entities.DailyStat{} + err := rows.Scan(&stat.Date, &stat.Total) + if err != nil { + return nil, err + } + stats = append(stats, stat) + } + + return stats, nil +} + +// Exists checks if a counter exists and belongs to the user +func (r *CounterRepository) Exists(ctx context.Context, id, userID int) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)` + + var exists bool + err := r.db.QueryRowContext(ctx, query, id, userID).Scan(&exists) + if err != nil { + return false, err + } + + return exists, nil +} diff --git a/internal/infrastructure/database/postgres/user_repository.go b/internal/infrastructure/database/postgres/user_repository.go new file mode 100644 index 0000000..ce96e3a --- /dev/null +++ b/internal/infrastructure/database/postgres/user_repository.go @@ -0,0 +1,146 @@ +package postgres + +import ( + "context" + "database/sql" + + "counter/internal/domain/entities" + "counter/internal/domain/repositories" +) + +// UserRepository implements the UserRepository interface for PostgreSQL +type UserRepository struct { + db *sql.DB +} + +// NewUserRepository creates a new user repository +func NewUserRepository(db *sql.DB) repositories.UserRepository { + return &UserRepository{db: db} +} + +// Create creates a new user +func (r *UserRepository) Create(ctx context.Context, user *entities.User) error { + query := ` + INSERT INTO users (username, email, password) + VALUES ($1, $2, $3) + RETURNING id, created_at, updated_at + ` + + err := r.db.QueryRowContext(ctx, query, user.Username, user.Email, user.Password). + Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt) + + if err != nil { + return err + } + + return nil +} + +// FindByID finds a user by ID +func (r *UserRepository) FindByID(ctx context.Context, id int) (*entities.User, error) { + query := ` + SELECT id, username, email, password, created_at, updated_at + FROM users + WHERE id = $1 + ` + + user := &entities.User{} + err := r.db.QueryRowContext(ctx, query, id). + Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, entities.ErrUserNotFound + } + if err != nil { + return nil, err + } + + return user, nil +} + +// FindByUsername finds a user by username +func (r *UserRepository) FindByUsername(ctx context.Context, username string) (*entities.User, error) { + query := ` + SELECT id, username, email, password, created_at, updated_at + FROM users + WHERE username = $1 + ` + + user := &entities.User{} + err := r.db.QueryRowContext(ctx, query, username). + Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, entities.ErrUserNotFound + } + if err != nil { + return nil, err + } + + return user, nil +} + +// FindByEmail finds a user by email +func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) { + query := ` + SELECT id, username, email, password, created_at, updated_at + FROM users + WHERE email = $1 + ` + + user := &entities.User{} + err := r.db.QueryRowContext(ctx, query, email). + Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, entities.ErrUserNotFound + } + if err != nil { + return nil, err + } + + return user, nil +} + +// Update updates a user +func (r *UserRepository) Update(ctx context.Context, user *entities.User) error { + query := ` + UPDATE users + SET username = $1, email = $2, password = $3, updated_at = CURRENT_TIMESTAMP + WHERE id = $4 + RETURNING updated_at + ` + + err := r.db.QueryRowContext(ctx, query, user.Username, user.Email, user.Password, user.ID). + Scan(&user.UpdatedAt) + + if err == sql.ErrNoRows { + return entities.ErrUserNotFound + } + if err != nil { + return err + } + + return nil +} + +// Delete deletes a user +func (r *UserRepository) Delete(ctx context.Context, id int) error { + query := `DELETE FROM users WHERE id = $1` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return entities.ErrUserNotFound + } + + return nil +} diff --git a/logger.go b/internal/infrastructure/logging/logger.go similarity index 58% rename from logger.go rename to internal/infrastructure/logging/logger.go index 01f8f09..362622d 100644 --- a/logger.go +++ b/internal/infrastructure/logging/logger.go @@ -1,4 +1,4 @@ -package main +package logging import ( "io" @@ -6,26 +6,44 @@ import ( "path/filepath" "time" + "counter/internal/infrastructure/config" + "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" ) -// Logger is the global logger instance -var Logger *logrus.Logger +// Logger interface defines the contract for logging +type Logger interface { + Info(args ...interface{}) + Infof(format string, args ...interface{}) + Warn(args ...interface{}) + Warnf(format string, args ...interface{}) + Error(args ...interface{}) + Errorf(format string, args ...interface{}) + Fatal(args ...interface{}) + Fatalf(format string, args ...interface{}) + WithFields(fields logrus.Fields) *logrus.Entry + WithError(err error) *logrus.Entry +} + +// LogrusLogger implements the Logger interface using logrus +type LogrusLogger struct { + *logrus.Logger +} // InitLogger initializes the structured logger with file output -func InitLogger(config *Config) error { - Logger = logrus.New() +func InitLogger(cfg *config.Config) (Logger, error) { + logger := logrus.New() // Set log level based on configuration - level, err := logrus.ParseLevel(config.LogLevel) + level, err := logrus.ParseLevel(cfg.LogLevel) if err != nil { level = logrus.InfoLevel } - Logger.SetLevel(level) + logger.SetLevel(level) // Set JSON formatter for structured logging - Logger.SetFormatter(&logrus.JSONFormatter{ + logger.SetFormatter(&logrus.JSONFormatter{ TimestampFormat: time.RFC3339, FieldMap: logrus.FieldMap{ logrus.FieldKeyTime: "timestamp", @@ -35,59 +53,38 @@ func InitLogger(config *Config) error { }) // Create log directory if it doesn't exist - if err := os.MkdirAll(config.LogDir, 0755); err != nil { - return err + if err := os.MkdirAll(cfg.LogDir, 0755); err != nil { + return nil, err } // Create log file with timestamp - logFile := filepath.Join(config.LogDir, "app.log") + logFile := filepath.Join(cfg.LogDir, "app.log") file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { - return err + return nil, err } // Set output to both file and stdout multiWriter := io.MultiWriter(os.Stdout, file) - Logger.SetOutput(multiWriter) + logger.SetOutput(multiWriter) // Log initialization with default fields - Logger.WithFields(logrus.Fields{ + logger.WithFields(logrus.Fields{ "service": "counter-app", - "environment": string(config.Environment), + "environment": string(cfg.Environment), "version": "1.0.0", }).Info("Logger initialized successfully") - return nil -} - -// GetLogger returns the global logger instance -func GetLogger() *logrus.Logger { - if Logger == nil { - // Fallback to standard logger if not initialized - logrus.SetFormatter(&logrus.JSONFormatter{ - TimestampFormat: time.RFC3339, - }) - return logrus.StandardLogger() - } - return Logger -} - -// GetLoggerWithDefaults returns a logger entry with default fields -func GetLoggerWithDefaults(env Environment) *logrus.Entry { - return Logger.WithFields(logrus.Fields{ - "service": "counter-app", - "environment": string(env), - "version": "1.0.0", - }) + return &LogrusLogger{Logger: logger}, nil } // LoggingMiddleware creates a Gin middleware for HTTP request logging -func LoggingMiddleware(config *Config) gin.HandlerFunc { +func LoggingMiddleware(logger Logger, cfg *config.Config) gin.HandlerFunc { return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { // Create structured log entry with default fields - entry := Logger.WithFields(logrus.Fields{ + entry := logger.WithFields(logrus.Fields{ "service": "counter-app", - "environment": string(config.Environment), + "environment": string(cfg.Environment), "version": "1.0.0", "method": param.Method, "path": param.Path, diff --git a/internal/infrastructure/metrics/prometheus.go b/internal/infrastructure/metrics/prometheus.go new file mode 100644 index 0000000..390cee3 --- /dev/null +++ b/internal/infrastructure/metrics/prometheus.go @@ -0,0 +1,103 @@ +package metrics + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// MetricsService interface defines the contract for metrics operations +type MetricsService interface { + RecordHTTPRequest(method, path string, statusCode int, duration time.Duration) + RecordDBOperation(operation, table string) + RecordAuthAttempt(action, result string) + StartMetricsServer(port string) + MetricsMiddleware() gin.HandlerFunc +} + +// PrometheusMetricsService implements MetricsService using Prometheus +type PrometheusMetricsService struct { + httpRequestsTotal *prometheus.CounterVec + httpRequestDuration *prometheus.HistogramVec + dbOperationsTotal *prometheus.CounterVec + authAttemptsTotal *prometheus.CounterVec +} + +// NewPrometheusMetricsService creates a new Prometheus metrics service +func NewPrometheusMetricsService() MetricsService { + return &PrometheusMetricsService{ + httpRequestsTotal: promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total number of HTTP requests", + }, + []string{"method", "path", "status"}, + ), + httpRequestDuration: promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "HTTP request duration in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"method", "path"}, + ), + dbOperationsTotal: promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "db_operations_total", + Help: "Total number of database operations", + }, + []string{"operation", "table"}, + ), + authAttemptsTotal: promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "auth_attempts_total", + Help: "Total number of authentication attempts", + }, + []string{"action", "result"}, + ), + } +} + +// RecordHTTPRequest records an HTTP request +func (m *PrometheusMetricsService) RecordHTTPRequest(method, path string, statusCode int, duration time.Duration) { + status := strconv.Itoa(statusCode) + m.httpRequestsTotal.WithLabelValues(method, path, status).Inc() + m.httpRequestDuration.WithLabelValues(method, path).Observe(duration.Seconds()) +} + +// RecordDBOperation records a database operation +func (m *PrometheusMetricsService) RecordDBOperation(operation, table string) { + m.dbOperationsTotal.WithLabelValues(operation, table).Inc() +} + +// RecordAuthAttempt records an authentication attempt +func (m *PrometheusMetricsService) RecordAuthAttempt(action, result string) { + m.authAttemptsTotal.WithLabelValues(action, result).Inc() +} + +// StartMetricsServer starts the Prometheus metrics server +func (m *PrometheusMetricsService) StartMetricsServer(port string) { + http.Handle("/metrics", promhttp.Handler()) + go func() { + if err := http.ListenAndServe(":"+port, nil); err != nil { + // Log error but don't fail the application + } + }() +} + +// MetricsMiddleware creates a Gin middleware for HTTP metrics +func (m *PrometheusMetricsService) MetricsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + + c.Next() + + duration := time.Since(start) + m.RecordHTTPRequest(c.Request.Method, c.FullPath(), c.Writer.Status(), duration) + } +} diff --git a/internal/infrastructure/security/jwt.go b/internal/infrastructure/security/jwt.go new file mode 100644 index 0000000..731d6ac --- /dev/null +++ b/internal/infrastructure/security/jwt.go @@ -0,0 +1,59 @@ +package security + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// JWTService interface defines the contract for JWT operations +type JWTService interface { + GenerateToken(userID int, username string) (string, error) + ValidateToken(tokenString string) (jwt.MapClaims, error) +} + +// JWTServiceImpl implements JWTService using golang-jwt +type JWTServiceImpl struct { + secret []byte +} + +// NewJWTService creates a new JWT service +func NewJWTService(secret string) JWTService { + return &JWTServiceImpl{ + secret: []byte(secret), + } +} + +// GenerateToken generates a JWT token for a user +func (j *JWTServiceImpl) GenerateToken(userID int, username string) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "username": username, + "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(j.secret) +} + +// ValidateToken validates a JWT token and returns the claims +func (j *JWTServiceImpl) ValidateToken(tokenString string) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return j.secret, nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} diff --git a/internal/infrastructure/security/password.go b/internal/infrastructure/security/password.go new file mode 100644 index 0000000..71e9b9d --- /dev/null +++ b/internal/infrastructure/security/password.go @@ -0,0 +1,29 @@ +package security + +import "golang.org/x/crypto/bcrypt" + +// PasswordService interface defines the contract for password operations +type PasswordService interface { + HashPassword(password string) (string, error) + CheckPasswordHash(password, hash string) bool +} + +// PasswordServiceImpl implements PasswordService using bcrypt +type PasswordServiceImpl struct{} + +// NewPasswordService creates a new password service +func NewPasswordService() PasswordService { + return &PasswordServiceImpl{} +} + +// HashPassword hashes a password using bcrypt +func (p *PasswordServiceImpl) HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + return string(bytes), err +} + +// CheckPasswordHash compares a password with its hash +func (p *PasswordServiceImpl) CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/internal/usecase/auth/service.go b/internal/usecase/auth/service.go new file mode 100644 index 0000000..e53a862 --- /dev/null +++ b/internal/usecase/auth/service.go @@ -0,0 +1,141 @@ +package auth + +import ( + "context" + + "counter/internal/domain/entities" + "counter/internal/domain/repositories" + "counter/internal/infrastructure/security" +) + +// AuthService handles authentication business logic +type AuthService struct { + userRepo repositories.UserRepository + passwordService security.PasswordService + jwtService security.JWTService +} + +// NewAuthService creates a new authentication service +func NewAuthService( + userRepo repositories.UserRepository, + passwordService security.PasswordService, + jwtService security.JWTService, +) *AuthService { + return &AuthService{ + userRepo: userRepo, + passwordService: passwordService, + jwtService: jwtService, + } +} + +// RegisterRequest represents a user registration request +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,max=50"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` +} + +// LoginRequest represents a user login request +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// AuthResponse represents an authentication response +type AuthResponse struct { + Token string `json:"token"` + User *entities.User `json:"user"` +} + +// Register registers a new user +func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*AuthResponse, error) { + // Check if username already exists + _, err := s.userRepo.FindByUsername(ctx, req.Username) + if err == nil { + return nil, entities.ErrUserAlreadyExists + } + if err != entities.ErrUserNotFound { + return nil, err + } + + // Check if email already exists + _, err = s.userRepo.FindByEmail(ctx, req.Email) + if err == nil { + return nil, entities.ErrUserAlreadyExists + } + if err != entities.ErrUserNotFound { + return nil, err + } + + // Hash password + hashedPassword, err := s.passwordService.HashPassword(req.Password) + if err != nil { + return nil, err + } + + // Create user + user := &entities.User{ + Username: req.Username, + Email: req.Email, + Password: hashedPassword, + } + + if err := s.userRepo.Create(ctx, user); err != nil { + return nil, err + } + + // Generate token + token, err := s.jwtService.GenerateToken(user.ID, user.Username) + if err != nil { + return nil, err + } + + // Clear password from response + user.ClearPassword() + + return &AuthResponse{ + Token: token, + User: user, + }, nil +} + +// Login authenticates a user +func (s *AuthService) Login(ctx context.Context, req *LoginRequest) (*AuthResponse, error) { + // Find user + user, err := s.userRepo.FindByUsername(ctx, req.Username) + if err != nil { + return nil, entities.ErrInvalidCredentials + } + + // Check password + if !s.passwordService.CheckPasswordHash(req.Password, user.Password) { + return nil, entities.ErrInvalidCredentials + } + + // Generate token + token, err := s.jwtService.GenerateToken(user.ID, user.Username) + if err != nil { + return nil, err + } + + // Clear password from response + user.ClearPassword() + + return &AuthResponse{ + Token: token, + User: user, + }, nil +} + +// GetCurrentUser retrieves the current authenticated user +func (s *AuthService) GetCurrentUser(ctx context.Context, userID int) (*entities.User, error) { + user, err := s.userRepo.FindByID(ctx, userID) + if err != nil { + return nil, err + } + + // Clear password from response + user.ClearPassword() + + return user, nil +} diff --git a/internal/usecase/counter/service.go b/internal/usecase/counter/service.go new file mode 100644 index 0000000..04d5478 --- /dev/null +++ b/internal/usecase/counter/service.go @@ -0,0 +1,133 @@ +package counter + +import ( + "context" + "time" + + "counter/internal/domain/entities" + "counter/internal/domain/repositories" +) + +// CounterService handles counter business logic +type CounterService struct { + counterRepo repositories.CounterRepository +} + +// NewCounterService creates a new counter service +func NewCounterService(counterRepo repositories.CounterRepository) *CounterService { + return &CounterService{ + counterRepo: counterRepo, + } +} + +// CreateCounterRequest represents a counter creation request +type CreateCounterRequest struct { + Name string `json:"name" binding:"required,min=1,max=100"` + Description string `json:"description" max:"500"` +} + +// UpdateCounterRequest represents a counter update request +type UpdateCounterRequest struct { + Name string `json:"name" binding:"required,min=1,max=100"` + Description string `json:"description" max:"500"` +} + +// IncrementRequest represents a counter increment request +type IncrementRequest struct { + Value int `json:"value" binding:"required"` +} + +// Create creates a new counter +func (s *CounterService) Create(ctx context.Context, userID int, req *CreateCounterRequest) (*entities.Counter, error) { + counter := &entities.Counter{ + UserID: userID, + Name: req.Name, + Description: req.Description, + } + + if err := counter.Validate(); err != nil { + return nil, err + } + + if err := s.counterRepo.Create(ctx, counter); err != nil { + return nil, err + } + + return counter, nil +} + +// Get retrieves a counter by ID +func (s *CounterService) Get(ctx context.Context, counterID, userID int) (*entities.CounterWithStats, error) { + return s.counterRepo.FindByID(ctx, counterID, userID) +} + +// List retrieves all counters for a user +func (s *CounterService) List(ctx context.Context, userID int, search string) ([]*entities.CounterWithStats, error) { + return s.counterRepo.FindByUserID(ctx, userID, search) +} + +// Update updates a counter +func (s *CounterService) Update(ctx context.Context, counterID, userID int, req *UpdateCounterRequest) (*entities.Counter, error) { + counter := &entities.Counter{ + ID: counterID, + UserID: userID, + Name: req.Name, + Description: req.Description, + } + + if err := counter.Validate(); err != nil { + return nil, err + } + + if err := s.counterRepo.Update(ctx, counter); err != nil { + return nil, err + } + + return counter, nil +} + +// Delete deletes a counter +func (s *CounterService) Delete(ctx context.Context, counterID, userID int) error { + return s.counterRepo.Delete(ctx, counterID, userID) +} + +// Increment increments/decrements a counter +func (s *CounterService) Increment(ctx context.Context, counterID, userID int, req *IncrementRequest) (*entities.CounterEntry, error) { + // Verify counter exists and belongs to user + exists, err := s.counterRepo.Exists(ctx, counterID, userID) + if err != nil { + return nil, err + } + if !exists { + return nil, entities.ErrCounterNotFound + } + + entry := &entities.CounterEntry{ + CounterID: counterID, + Value: req.Value, + Date: time.Now().Truncate(24 * time.Hour), // Truncate to date only + } + + if err := entry.Validate(); err != nil { + return nil, err + } + + if err := s.counterRepo.AddEntry(ctx, entry); err != nil { + return nil, err + } + + return entry, nil +} + +// GetEntries retrieves entries for a counter +func (s *CounterService) GetEntries(ctx context.Context, counterID, userID int, startDate, endDate *time.Time) ([]*entities.CounterEntry, error) { + return s.counterRepo.GetEntries(ctx, counterID, userID, startDate, endDate) +} + +// GetStats retrieves statistics for a counter +func (s *CounterService) GetStats(ctx context.Context, counterID, userID int, days int) ([]*entities.DailyStat, error) { + if days <= 0 { + days = 30 // Default to 30 days + } + return s.counterRepo.GetDailyStats(ctx, counterID, userID, days) +} diff --git a/main.go b/main.go deleted file mode 100644 index d09d4c3..0000000 --- a/main.go +++ /dev/null @@ -1,113 +0,0 @@ -package main - -import ( - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" -) - -func main() { - // Load configuration with environment file precedence - config := LoadConfig() - - // Initialize structured logger - if err := InitLogger(config); err != nil { - Logger.Fatal("Failed to initialize logger:", err) - } - - // Set Gin mode based on configuration - gin.SetMode(config.GinMode) - - // Initialize JWT with configuration - InitJWTWithConfig(config) - - // Initialize database with configuration - if err := InitDBWithConfig(config); err != nil { - Logger.WithError(err).Fatal("Failed to initialize database") - } - - // Create tables - if err := CreateTables(); err != nil { - Logger.WithError(err).Fatal("Failed to create tables") - } - - // Initialize Prometheus metrics - InitMetrics() - - // Start metrics server on separate port - StartMetricsServer(config.MetricsPort) - - // Create Gin router - r := gin.Default() - - // Configure CORS - corsConfig := cors.DefaultConfig() - corsConfig.AllowOrigins = []string{"http://localhost:3000", "http://localhost:5173"} // React dev servers - corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} - corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"} - corsConfig.AllowCredentials = true - r.Use(cors.New(corsConfig)) - - // Add metrics middleware - r.Use(MetricsMiddleware()) - - // Add logging middleware - r.Use(LoggingMiddleware(config)) - - // Health check endpoint - r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) - }) - - // API routes - api := r.Group("/api/v1") - { - // Authentication routes - auth := api.Group("/auth") - { - auth.POST("/register", RegisterHandler) - auth.POST("/login", LoginHandler) - auth.GET("/me", AuthMiddleware(), GetCurrentUserHandler) - } - - // Counter routes (protected) - counters := api.Group("/counters") - counters.Use(AuthMiddleware()) - { - counters.POST("", CreateCounterHandler) - counters.GET("", GetCountersHandler) - counters.GET("/:id", GetCounterHandler) - counters.PUT("/:id", UpdateCounterHandler) - counters.DELETE("/:id", DeleteCounterHandler) - counters.POST("/:id/increment", IncrementCounterHandler) - counters.GET("/:id/entries", GetCounterEntriesHandler) - counters.GET("/:id/stats", GetCounterStatsHandler) - } - } - - // Serve static files (React app) - r.Static("/static", "./frontend/build/static") - r.StaticFile("/", "./frontend/build/index.html") - r.NoRoute(func(c *gin.Context) { - c.File("./frontend/build/index.html") - }) - - // Start server - port := config.Port - - Logger.WithFields(logrus.Fields{ - "port": port, - "metrics_port": config.MetricsPort, - "log_dir": config.LogDir, - "log_volume": config.LogVolume, - }).Info("🚀 Starting Counter Application Server") - - Logger.WithFields(logrus.Fields{ - "health_url": "http://localhost:" + port + "/health", - "api_url": "http://localhost:" + port + "/api/v1", - "frontend_url": "http://localhost:" + port + "/", - "metrics_url": "http://localhost:" + config.MetricsPort + "/metrics", - }).Info("✅ Server is ready and accepting connections") - - Logger.Fatal(r.Run(":" + port)) -} diff --git a/metrics.go b/metrics.go deleted file mode 100644 index 642b799..0000000 --- a/metrics.go +++ /dev/null @@ -1,137 +0,0 @@ -package main - -import ( - "net/http" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -var ( - // HTTP request metrics - httpRequestsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "http_requests_total", - Help: "Total number of HTTP requests", - }, - []string{"method", "endpoint", "status_code"}, - ) - - // HTTP request duration metrics - httpRequestDuration = prometheus.NewHistogramVec( - prometheus.HistogramOpts{ - Name: "http_request_duration_seconds", - Help: "HTTP request duration in seconds", - Buckets: prometheus.DefBuckets, - }, - []string{"method", "endpoint"}, - ) - - // API endpoint specific metrics - apiRequestsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "api_requests_total", - Help: "Total number of API requests by endpoint", - }, - []string{"endpoint", "method"}, - ) - - // Database operation metrics - dbOperationsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "db_operations_total", - Help: "Total number of database operations", - }, - []string{"operation", "table"}, - ) - - // Authentication metrics - authAttemptsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "auth_attempts_total", - Help: "Total number of authentication attempts", - }, - []string{"type", "status"}, - ) -) - -// InitMetrics initializes Prometheus metrics -func InitMetrics() { - // Register all metrics - prometheus.MustRegister(httpRequestsTotal) - prometheus.MustRegister(httpRequestDuration) - prometheus.MustRegister(apiRequestsTotal) - prometheus.MustRegister(dbOperationsTotal) - prometheus.MustRegister(authAttemptsTotal) -} - -// StartMetricsServer starts the metrics server on a separate port -func StartMetricsServer(port string) { - // Create a new HTTP server for metrics - metricsMux := http.NewServeMux() - metricsMux.Handle("/metrics", promhttp.Handler()) - - // Add health check for metrics server - metricsMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("Metrics server is healthy")) - }) - - server := &http.Server{ - Addr: "localhost:" + port, - Handler: metricsMux, - ReadTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - } - - go func() { - Logger.Printf("📈 Metrics server starting on http://localhost:%s/metrics", port) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - Logger.Printf("❌ Metrics server failed to start: %v", err) - } - }() -} - -// MetricsMiddleware is a Gin middleware to collect HTTP metrics -func MetricsMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - start := time.Now() - path := c.FullPath() - method := c.Request.Method - - // Process request - c.Next() - - // Calculate duration - duration := time.Since(start).Seconds() - statusCode := strconv.Itoa(c.Writer.Status()) - - // Record metrics - httpRequestsTotal.WithLabelValues(method, path, statusCode).Inc() - httpRequestDuration.WithLabelValues(method, path).Observe(duration) - - // Record API-specific metrics for API routes - if strings.HasPrefix(c.Request.URL.Path, "/api") { - apiRequestsTotal.WithLabelValues(path, method).Inc() - } - } -} - -// RecordAPICall records a specific API endpoint call -func RecordAPICall(endpoint, method string) { - apiRequestsTotal.WithLabelValues(endpoint, method).Inc() -} - -// RecordDBOperation records a database operation -func RecordDBOperation(operation, table string) { - dbOperationsTotal.WithLabelValues(operation, table).Inc() -} - -// RecordAuthAttempt records an authentication attempt -func RecordAuthAttempt(authType, status string) { - authAttemptsTotal.WithLabelValues(authType, status).Inc() -} diff --git a/models.go b/models.go deleted file mode 100644 index 19f41ea..0000000 --- a/models.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "time" -) - -// User represents a registered user -type User struct { - ID int `json:"id" db:"id"` - Username string `json:"username" db:"username"` - Email string `json:"email" db:"email"` - Password string `json:"-" db:"password"` // Hidden from JSON - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// Counter represents a counter entity -type Counter struct { - ID int `json:"id" db:"id"` - UserID *int `json:"user_id,omitempty" db:"user_id"` // nil for anonymous users - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// CounterEntry represents a single increment/decrement entry -type CounterEntry struct { - ID int `json:"id" db:"id"` - CounterID int `json:"counter_id" db:"counter_id"` - Value int `json:"value" db:"value"` // +1 for increment, -1 for decrement - Date time.Time `json:"date" db:"date"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// CounterWithStats represents a counter with aggregated statistics -type CounterWithStats struct { - Counter - TotalValue int `json:"total_value"` - TodayValue int `json:"today_value"` - WeekValue int `json:"week_value"` - MonthValue int `json:"month_value"` - EntryCount int `json:"entry_count"` -} - -// AnonymousCounter represents a counter for anonymous users (stored in localStorage) -type AnonymousCounter struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - TotalValue int `json:"total_value"` - Entries map[string]int `json:"entries"` // date -> count - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Request/Response DTOs - -type RegisterRequest struct { - Username string `json:"username" binding:"required,min=3,max=50"` - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=6"` -} - -type LoginRequest struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` -} - -type AuthResponse struct { - Token string `json:"token"` - User User `json:"user"` -} - -type CreateCounterRequest struct { - Name string `json:"name" binding:"required,min=1,max=100"` - Description string `json:"description" max:"500"` -} - -type UpdateCounterRequest struct { - Name string `json:"name" binding:"required,min=1,max=100"` - Description string `json:"description" max:"500"` -} - -type IncrementRequest struct { - Value int `json:"value" binding:"required"` -} - -type CounterStatsRequest struct { - StartDate *time.Time `json:"start_date"` - EndDate *time.Time `json:"end_date"` -}