refactor
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
aovantsev
2025-10-10 19:56:06 +03:00
parent f081c9d947
commit 73ed514a34
30 changed files with 1728 additions and 1020 deletions

View File

@@ -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)
}

View File

@@ -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})
}

View File

@@ -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 <token>"
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()
}
}

View File

@@ -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 ""
})
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")
)

View File

@@ -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 = ""
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -0,0 +1,225 @@
package config
import (
"fmt"
"log"
"os"
"strings"
"github.com/joho/godotenv"
)
type Environment string
const (
Development Environment = "development"
Staging Environment = "staging"
Production Environment = "production"
)
type Config struct {
Environment Environment
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
func LoadConfig() *Config {
// Load environment files in priority order
loadEnvironmentFiles()
// Get environment
env := getEnvironment()
// Load configuration
config := &Config{
Environment: env,
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)
logConfig(config)
return config
}
// loadEnvironmentFiles loads environment files in priority order
func loadEnvironmentFiles() {
// Get environment first (from system env or default)
env := getEnvironmentFromSystem()
log.Printf("🔍 Detected environment: %s", env)
// Define file loading order (later files override earlier ones)
files := []string{
".env", // Base configuration
fmt.Sprintf(".env.%s", env), // Environment-specific
}
// Load files in order
for _, file := range files {
if _, err := os.Stat(file); err == nil {
if err := godotenv.Load(file); err != nil {
log.Printf("⚠️ Warning: Could not load %s: %v", file, err)
} else {
log.Printf("✅ Loaded: %s", file)
}
} else {
log.Printf("❌ Not found: %s", file)
}
}
}
// getEnvironmentFromSystem gets environment from system variables only
func getEnvironmentFromSystem() string {
// Check if ENVIRONMENT is already set
if env := os.Getenv("ENVIRONMENT"); env != "" {
return strings.ToLower(env)
}
// Fallback detection
if ginMode := os.Getenv("GIN_MODE"); ginMode == "release" {
return "production"
}
return "development"
}
// getEnvironment gets the current environment
func getEnvironment() Environment {
env := strings.ToLower(getEnv("ENVIRONMENT", "development"))
switch env {
case "development", "dev":
return Development
case "staging", "stage":
return Staging
case "production", "prod":
return Production
default:
log.Printf("⚠️ Unknown environment '%s', defaulting to development", env)
return Development
}
}
// getGinMode returns the appropriate Gin mode for the environment
func getGinMode(env Environment) string {
switch env {
case Production, Staging:
return "release"
case Development:
return "debug"
default:
return "debug"
}
}
// getLogLevel returns the appropriate log level for the environment
func getLogLevel(env Environment) string {
switch env {
case Production:
return "info" // Changed from "warn" to "info" to capture more logs
case Staging:
return "info"
case Development:
return "debug"
default:
return "debug"
}
}
// getDefaultDatabaseURL returns default database URL for environment
func getDefaultDatabaseURL(env Environment) string {
switch env {
case Development:
return "postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
case Staging:
return "postgres://postgres:password@postgres:5432/counter_db?sslmode=disable"
case Production:
return "postgres://postgres:password@postgres:5432/counter_db?sslmode=require"
default:
return "postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
}
}
// getEnv gets environment variable with default value
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getRequiredEnv gets required environment variable
func getRequiredEnv(key string) string {
value := os.Getenv(key)
if value == "" {
log.Fatalf("❌ Required environment variable %s is not set", key)
}
return value
}
// logConfig logs configuration (without sensitive data)
func logConfig(config *Config) {
// Use standard log for configuration banner since logger might not be initialized yet
log.Printf("")
log.Printf("╔══════════════════════════════════════════════════════════════╗")
log.Printf("║ COUNTER APPLICATION ║")
log.Printf("║ ║")
log.Printf("║ 🌍 ENVIRONMENT: %-15s ║", strings.ToUpper(string(config.Environment)))
log.Printf("║ 🚀 MODE: %-20s ║", config.GinMode)
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) ║")
log.Printf("║ • .env.%s (environment-specific) ║", config.Environment)
log.Printf("║ ║")
log.Printf("║ 🔐 Security: ║")
log.Printf("║ • Database: %s ║", maskDatabaseURL(config.DatabaseURL))
log.Printf("║ • JWT Secret: %s ║", maskSecret(config.JWTSecret))
log.Printf("║ ║")
log.Printf("╚══════════════════════════════════════════════════════════════╝")
log.Printf("")
}
// maskDatabaseURL masks sensitive parts of database URL
func maskDatabaseURL(url string) string {
// Simple masking - replace password with ***
if strings.Contains(url, "://") {
parts := strings.Split(url, "://")
if len(parts) == 2 {
// Replace password in connection string
masked := strings.Replace(parts[1], ":", ":***@", 1)
return parts[0] + "://" + masked
}
}
return "***"
}
// maskSecret masks JWT secret for logging
func maskSecret(secret string) string {
if len(secret) <= 8 {
return "***"
}
return secret[:4] + "***" + secret[len(secret)-4:]
}

View File

@@ -0,0 +1,90 @@
package postgres
import (
"database/sql"
"fmt"
"counter/internal/infrastructure/config"
"counter/internal/infrastructure/logging"
_ "github.com/lib/pq"
)
// Connection manages the database connection
type Connection struct {
db *sql.DB
}
// 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 nil, fmt.Errorf("failed to open database: %w", err)
}
// Test the connection
if err = db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
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 (c *Connection) CreateTables() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS counters (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS counter_entries (
id SERIAL PRIMARY KEY,
counter_id INTEGER REFERENCES counters(id) ON DELETE CASCADE,
value INTEGER NOT NULL,
date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_counters_user_id ON counters(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_counter_entries_counter_id ON counter_entries(counter_id)`,
`CREATE INDEX IF NOT EXISTS idx_counter_entries_date ON counter_entries(date)`,
}
for _, query := range queries {
if _, err := c.db.Exec(query); err != nil {
return fmt.Errorf("failed to execute query: %w", err)
}
}
return nil
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 ""
})
}

View File

@@ -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)
}
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}