diff --git a/.drone.yml b/.drone.yml index e9d46f2..a0424db 100644 --- a/.drone.yml +++ b/.drone.yml @@ -20,7 +20,7 @@ steps: - docker rm counter-app || true # Run the new container with environment variables - - docker run -d --name counter-app -e ENVIRONMENT=production -e JWT_SECRET=your-super-secure-production-secret -e DATABASE_URL=postgres://postgres:password@infra-postgres-1:5432/counter_db?sslmode=disable -e GIN_MODE=release -e LOG_LEVEL=warn -p 8080:8080 --network infra_backend_nw localhost:5000/counter:latest + - docker run -d --name counter-app -e ENVIRONMENT=production -e JWT_SECRET=your-super-secure-production-secret -e DATABASE_URL=postgres://postgres:password@postgres:5432/counter_db?sslmode=disable -e GIN_MODE=release -e LOG_LEVEL=warn -p 8080:8080 -v app_logs:/app/logs --network infra_backend_nw localhost:5000/counter:latest volumes: - name: dockersock diff --git a/.env b/.env index b1baa65..f921b80 100644 --- a/.env +++ b/.env @@ -3,3 +3,5 @@ DATABASE_URL=postgres://postgres:password@localhost:5432/counter_db?sslmode=disa PORT=8080 LOG_LEVEL=info GIN_MODE=debug +METRICS_PORT=9090 +JWT_SECRET=your-super-secure-development-secret-key-here diff --git a/.env.production b/.env.production index 3512ca5..a6859d4 100644 --- a/.env.production +++ b/.env.production @@ -5,3 +5,4 @@ JWT_SECRET=super-secure-production-secret-change-this PORT=8080 GIN_MODE=release LOG_LEVEL=warn +METRICS_PORT=9090 diff --git a/Dockerfile b/Dockerfile index e57d2f7..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 @@ -50,6 +52,9 @@ COPY --from=go-builder /app/.env* ./ # Copy the React build from frontend-builder stage COPY --from=frontend-builder /app/frontend/build ./frontend/build +# Create log directory with proper permissions +RUN mkdir -p /app/logs && chmod 755 /app/logs + # Expose port EXPOSE 8080 diff --git a/_.env.development b/_.env.development index 5d286aa..8da925f 100644 --- a/_.env.development +++ b/_.env.development @@ -3,5 +3,8 @@ ENVIRONMENT=development DATABASE_URL=postgres://postgres:password@postgres:5432/counter_db?sslmode=disable JWT_SECRET=dev-secret-key-change-in-production PORT=8080 +METRICS_PORT=9090 GIN_MODE=debug LOG_LEVEL=debug +LOG_DIR=/app/logs +LOG_VOLUME=counter_logs 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 a4ab630..0000000 --- a/auth.go +++ /dev/null @@ -1,230 +0,0 @@ -package main - -import ( - "crypto/rand" - "database/sql" - "fmt" - "log" - "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() - log.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 { - 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 { - 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 - } - - 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 { - 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 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials 1"}) - return - } else if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) - return - } - - // Check password - if !checkPasswordHash(req.Password, user.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 = "" - - 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 5fd6d67..0000000 --- a/counters.go +++ /dev/null @@ -1,356 +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 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter"}) - return - } - - 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 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counters"}) - return - } - defer rows.Close() - - 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/docker-compose.yml b/docker-compose.yml index 57b8d60..4860579 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,26 +1,11 @@ services: - postgres: - image: postgres:15-alpine - environment: - POSTGRES_DB: counter_db - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - app: build: . ports: - "8080:8080" environment: - ENVIRONMENT=development + - LOG_VOLUME=${LOG_VOLUME:-counter_logs} env_file: - .env.development depends_on: @@ -28,6 +13,4 @@ services: condition: service_healthy volumes: - ./frontend/build:/app/frontend/build - -volumes: - postgres_data: + - ${LOG_VOLUME:-counter_logs}:/app/logs diff --git a/env.example b/env.example index ec9dc63..59575ac 100644 --- a/env.example +++ b/env.example @@ -6,7 +6,13 @@ JWT_SECRET=your-super-secret-jwt-key-change-in-production # Server Configuration PORT=8080 +METRICS_PORT=9090 GIN_MODE=release +# Logging Configuration +LOG_LEVEL=info +LOG_DIR=/app/logs +LOG_VOLUME=counter_logs + # Frontend Configuration (for development) REACT_APP_API_URL=http://localhost:8080/api/v1 diff --git a/go.mod b/go.mod index 217c581..6ca0847 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module counter -go 1.21 +go 1.21.0 require ( github.com/gin-contrib/cors v1.7.0 @@ -8,12 +8,16 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 - golang.org/x/crypto v0.23.0 + github.com/prometheus/client_golang v1.21.0 + github.com/sirupsen/logrus v1.9.3 + golang.org/x/crypto v0.31.0 ) require ( + github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect @@ -23,19 +27,24 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + 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/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/protobuf v1.36.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0d7f5af..ed4059c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= @@ -30,21 +34,25 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -56,12 +64,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA= +github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -72,8 +92,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= @@ -81,20 +102,19 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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 90% rename from config.go rename to internal/infrastructure/config/config.go index 264591f..0800c52 100644 --- a/config.go +++ b/internal/infrastructure/config/config.go @@ -1,4 +1,4 @@ -package main +package config import ( "fmt" @@ -22,9 +22,12 @@ type Config struct { DatabaseURL string JWTSecret string Port string + MetricsPort string GinMode string LogLevel string Debug bool + LogDir string + LogVolume string } // LoadConfig loads configuration with proper environment file precedence @@ -41,9 +44,12 @@ func LoadConfig() *Config { DatabaseURL: getEnv("DATABASE_URL", getDefaultDatabaseURL(env)), JWTSecret: getRequiredEnv("JWT_SECRET"), Port: getEnv("PORT", "8080"), + MetricsPort: getEnv("METRICS_PORT", "9090"), GinMode: getGinMode(env), LogLevel: getLogLevel(env), Debug: env == Development, + LogDir: getEnv("LOG_DIR", "/app/logs"), + LogVolume: getEnv("LOG_VOLUME", "counter_logs"), } // Log configuration (without sensitive data) @@ -127,7 +133,7 @@ func getGinMode(env Environment) string { func getLogLevel(env Environment) string { switch env { case Production: - return "warn" + return "info" // Changed from "warn" to "info" to capture more logs case Staging: return "info" case Development: @@ -170,7 +176,7 @@ func getRequiredEnv(key string) string { // logConfig logs configuration (without sensitive data) func logConfig(config *Config) { - // Environment banner + // Use standard log for configuration banner since logger might not be initialized yet log.Printf("") log.Printf("╔══════════════════════════════════════════════════════════════╗") log.Printf("║ COUNTER APPLICATION ║") @@ -180,6 +186,9 @@ func logConfig(config *Config) { log.Printf("║ 🔧 DEBUG: %-20s ║", fmt.Sprintf("%t", config.Debug)) log.Printf("║ 📊 LOG LEVEL: %-15s ║", config.LogLevel) log.Printf("║ 🌐 PORT: %-20s ║", config.Port) + log.Printf("║ 📈 METRICS PORT: %-15s ║", config.MetricsPort) + log.Printf("║ 📝 LOG DIR: %-20s ║", config.LogDir) + log.Printf("║ 📦 LOG VOLUME: %-18s ║", config.LogVolume) log.Printf("║ ║") log.Printf("║ 📁 Configuration Files Loaded: ║") log.Printf("║ • .env (base configuration) ║") diff --git a/database.go b/internal/infrastructure/database/postgres/connection.go similarity index 58% rename from database.go rename to internal/infrastructure/database/postgres/connection.go index b2369f0..abb390e 100644 --- a/database.go +++ b/internal/infrastructure/database/postgres/connection.go @@ -1,52 +1,56 @@ -package main +package postgres import ( "database/sql" "fmt" - "log" - "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) } - log.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, @@ -77,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) } } - log.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/internal/infrastructure/logging/logger.go b/internal/infrastructure/logging/logger.go new file mode 100644 index 0000000..362622d --- /dev/null +++ b/internal/infrastructure/logging/logger.go @@ -0,0 +1,111 @@ +package logging + +import ( + "io" + "os" + "path/filepath" + "time" + + "counter/internal/infrastructure/config" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +// 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(cfg *config.Config) (Logger, error) { + logger := logrus.New() + + // Set log level based on configuration + level, err := logrus.ParseLevel(cfg.LogLevel) + if err != nil { + level = logrus.InfoLevel + } + logger.SetLevel(level) + + // Set JSON formatter for structured logging + logger.SetFormatter(&logrus.JSONFormatter{ + TimestampFormat: time.RFC3339, + FieldMap: logrus.FieldMap{ + logrus.FieldKeyTime: "timestamp", + logrus.FieldKeyLevel: "level", + logrus.FieldKeyMsg: "message", + }, + }) + + // Create log directory if it doesn't exist + if err := os.MkdirAll(cfg.LogDir, 0755); err != nil { + return nil, err + } + + // Create log file with timestamp + 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 nil, err + } + + // Set output to both file and stdout + multiWriter := io.MultiWriter(os.Stdout, file) + logger.SetOutput(multiWriter) + + // Log initialization with default fields + logger.WithFields(logrus.Fields{ + "service": "counter-app", + "environment": string(cfg.Environment), + "version": "1.0.0", + }).Info("Logger initialized successfully") + + return &LogrusLogger{Logger: logger}, nil +} + +// LoggingMiddleware creates a Gin middleware for HTTP request logging +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{ + "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/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 d92e0df..0000000 --- a/main.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -import ( - "log" - - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" -) - -func main() { - // Load configuration with environment file precedence - config := LoadConfig() - - // 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 { - log.Fatal("Failed to initialize database :", err) - } - - // Create tables - if err := CreateTables(); err != nil { - log.Fatal("Failed to create tables:", err) - } - - // 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)) - - // 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 - - log.Printf("") - log.Printf("🚀 Starting Counter Application Server...") - log.Printf(" 🌐 Listening on: http://localhost:%s", port) - log.Printf(" 📊 Health check: http://localhost:%s/health", port) - log.Printf(" 🔗 API endpoint: http://localhost:%s/api/v1", port) - log.Printf(" 🎨 Frontend: http://localhost:%s/", port) - log.Printf("") - log.Printf("✅ Server is ready and accepting connections!") - log.Printf("") - - log.Fatal(r.Run(":" + port)) -} 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"` -}