This commit is contained in:
@@ -11,10 +11,12 @@ RUN go mod download
|
|||||||
|
|
||||||
# Copy source code and environment files
|
# Copy source code and environment files
|
||||||
COPY *.go ./
|
COPY *.go ./
|
||||||
|
COPY internal/ ./internal/
|
||||||
|
COPY cmd/ ./cmd/
|
||||||
COPY .env* ./
|
COPY .env* ./
|
||||||
|
|
||||||
# Build the application
|
# 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
|
# Build stage for React frontend
|
||||||
FROM node:18-alpine AS frontend-builder
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|||||||
237
auth.go
237
auth.go
@@ -1,237 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
var jwtSecret []byte
|
|
||||||
|
|
||||||
// InitJWT initializes JWT secret (legacy function)
|
|
||||||
func InitJWT() {
|
|
||||||
secret := os.Getenv("JWT_SECRET")
|
|
||||||
if secret == "" {
|
|
||||||
// Generate a random secret for development
|
|
||||||
secret = generateRandomSecret()
|
|
||||||
Logger.Println("Warning: Using generated JWT secret. Set JWT_SECRET environment variable for production.")
|
|
||||||
}
|
|
||||||
jwtSecret = []byte(secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitJWTWithConfig initializes JWT secret with configuration
|
|
||||||
func InitJWTWithConfig(config *Config) {
|
|
||||||
jwtSecret = []byte(config.JWTSecret)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateRandomSecret generates a random secret for JWT
|
|
||||||
func generateRandomSecret() string {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
rand.Read(b)
|
|
||||||
return fmt.Sprintf("%x", b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashPassword hashes a password using bcrypt
|
|
||||||
func hashPassword(password string) (string, error) {
|
|
||||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
|
||||||
return string(bytes), err
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkPasswordHash compares a password with its hash
|
|
||||||
func checkPasswordHash(password, hash string) bool {
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateToken generates a JWT token for a user
|
|
||||||
func generateToken(userID int, username string) (string, error) {
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"user_id": userID,
|
|
||||||
"username": username,
|
|
||||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
|
||||||
"iat": time.Now().Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
return token.SignedString(jwtSecret)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateToken validates a JWT token and returns the claims
|
|
||||||
func validateToken(tokenString string) (jwt.MapClaims, error) {
|
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
||||||
}
|
|
||||||
return jwtSecret, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthMiddleware validates JWT token and sets user context
|
|
||||||
func AuthMiddleware() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract token from "Bearer <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 := validateToken(tokenString)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set user information in context
|
|
||||||
c.Set("user_id", int(claims["user_id"].(float64)))
|
|
||||||
c.Set("username", claims["username"])
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterHandler handles user registration
|
|
||||||
func RegisterHandler(c *gin.Context) {
|
|
||||||
var req RegisterRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
RecordAuthAttempt("register", "bad_request")
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username already exists
|
|
||||||
var existingUser User
|
|
||||||
err := db.QueryRow("SELECT id FROM users WHERE username = $1 OR email = $2", req.Username, req.Email).Scan(&existingUser.ID)
|
|
||||||
if err != sql.ErrNoRows {
|
|
||||||
RecordAuthAttempt("register", "conflict")
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Username or email already exists"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
hashedPassword, err := hashPassword(req.Password)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create user
|
|
||||||
var user User
|
|
||||||
err = db.QueryRow(
|
|
||||||
"INSERT INTO users (username, email, password) VALUES ($1, $2, $3) RETURNING id, username, email, created_at, updated_at",
|
|
||||||
req.Username, req.Email, hashedPassword,
|
|
||||||
).Scan(&user.ID, &user.Username, &user.Email, &user.CreatedAt, &user.UpdatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate token
|
|
||||||
token, err := generateToken(user.ID, user.Username)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
RecordAuthAttempt("register", "success")
|
|
||||||
c.JSON(http.StatusCreated, AuthResponse{
|
|
||||||
Token: token,
|
|
||||||
User: user,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginHandler handles user login
|
|
||||||
func LoginHandler(c *gin.Context) {
|
|
||||||
var req LoginRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
RecordAuthAttempt("login", "bad_request")
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find user
|
|
||||||
var user User
|
|
||||||
err := db.QueryRow(
|
|
||||||
"SELECT id, username, email, password, created_at, updated_at FROM users WHERE username = $1",
|
|
||||||
req.Username,
|
|
||||||
).Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt)
|
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
RecordAuthAttempt("login", "user_not_found")
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials 1"})
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
RecordAuthAttempt("login", "database_error")
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check password
|
|
||||||
if !checkPasswordHash(req.Password, user.Password) {
|
|
||||||
RecordAuthAttempt("login", "invalid_password")
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials 2"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate token
|
|
||||||
token, err := generateToken(user.ID, user.Username)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear password from response
|
|
||||||
user.Password = ""
|
|
||||||
|
|
||||||
RecordAuthAttempt("login", "success")
|
|
||||||
c.JSON(http.StatusOK, AuthResponse{
|
|
||||||
Token: token,
|
|
||||||
User: user,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentUserHandler returns the current authenticated user
|
|
||||||
func GetCurrentUserHandler(c *gin.Context) {
|
|
||||||
userID := c.GetInt("user_id")
|
|
||||||
|
|
||||||
var user User
|
|
||||||
err := db.QueryRow(
|
|
||||||
"SELECT id, username, email, created_at, updated_at FROM users WHERE id = $1",
|
|
||||||
userID,
|
|
||||||
).Scan(&user.ID, &user.Username, &user.Email, &user.CreatedAt, &user.UpdatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
76
cmd/api/main.go
Normal file
76
cmd/api/main.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
362
counters.go
362
counters.go
@@ -1,362 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateCounterHandler creates a new counter
|
|
||||||
func CreateCounterHandler(c *gin.Context) {
|
|
||||||
var req CreateCounterRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := c.GetInt("user_id")
|
|
||||||
|
|
||||||
var counter Counter
|
|
||||||
err := db.QueryRow(
|
|
||||||
"INSERT INTO counters (user_id, name, description) VALUES ($1, $2, $3) RETURNING id, user_id, name, description, created_at, updated_at",
|
|
||||||
userID, req.Name, req.Description,
|
|
||||||
).Scan(&counter.ID, &counter.UserID, &counter.Name, &counter.Description, &counter.CreatedAt, &counter.UpdatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
RecordDBOperation("insert", "counters")
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
RecordDBOperation("insert", "counters")
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, counter)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCountersHandler retrieves all counters for the authenticated user
|
|
||||||
func GetCountersHandler(c *gin.Context) {
|
|
||||||
userID := c.GetInt("user_id")
|
|
||||||
search := c.Query("search")
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at,
|
|
||||||
COALESCE(SUM(ce.value), 0) as total_value,
|
|
||||||
COALESCE(SUM(CASE WHEN ce.date = CURRENT_DATE THEN ce.value ELSE 0 END), 0) as today_value,
|
|
||||||
COALESCE(SUM(CASE WHEN ce.date >= CURRENT_DATE - INTERVAL '7 days' THEN ce.value ELSE 0 END), 0) as week_value,
|
|
||||||
COALESCE(SUM(CASE WHEN ce.date >= DATE_TRUNC('month', CURRENT_DATE) THEN ce.value ELSE 0 END), 0) as month_value,
|
|
||||||
COUNT(ce.id) as entry_count
|
|
||||||
FROM counters c
|
|
||||||
LEFT JOIN counter_entries ce ON c.id = ce.counter_id
|
|
||||||
WHERE c.user_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
args := []interface{}{userID}
|
|
||||||
if search != "" {
|
|
||||||
query += " AND (c.name ILIKE $2 OR c.description ILIKE $2)"
|
|
||||||
args = append(args, "%"+search+"%")
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " GROUP BY c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at ORDER BY c.updated_at DESC"
|
|
||||||
|
|
||||||
rows, err := db.Query(query, args...)
|
|
||||||
if err != nil {
|
|
||||||
RecordDBOperation("select", "counters")
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counters"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
RecordDBOperation("select", "counters")
|
|
||||||
|
|
||||||
counters := []CounterWithStats{} // Initialize as empty slice, not nil
|
|
||||||
for rows.Next() {
|
|
||||||
var counter CounterWithStats
|
|
||||||
err := rows.Scan(
|
|
||||||
&counter.ID, &counter.UserID, &counter.Name, &counter.Description,
|
|
||||||
&counter.CreatedAt, &counter.UpdatedAt, &counter.TotalValue,
|
|
||||||
&counter.TodayValue, &counter.WeekValue, &counter.MonthValue,
|
|
||||||
&counter.EntryCount,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan counter"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
counters = append(counters, counter)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, counters)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCounterHandler retrieves a specific counter by ID
|
|
||||||
func GetCounterHandler(c *gin.Context) {
|
|
||||||
userID := c.GetInt("user_id")
|
|
||||||
counterID, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var counter CounterWithStats
|
|
||||||
err = db.QueryRow(`
|
|
||||||
SELECT c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at,
|
|
||||||
COALESCE(SUM(ce.value), 0) as total_value,
|
|
||||||
COALESCE(SUM(CASE WHEN ce.date = CURRENT_DATE THEN ce.value ELSE 0 END), 0) as today_value,
|
|
||||||
COALESCE(SUM(CASE WHEN ce.date >= CURRENT_DATE - INTERVAL '7 days' THEN ce.value ELSE 0 END), 0) as week_value,
|
|
||||||
COALESCE(SUM(CASE WHEN ce.date >= DATE_TRUNC('month', CURRENT_DATE) THEN ce.value ELSE 0 END), 0) as month_value,
|
|
||||||
COUNT(ce.id) as entry_count
|
|
||||||
FROM counters c
|
|
||||||
LEFT JOIN counter_entries ce ON c.id = ce.counter_id
|
|
||||||
WHERE c.id = $1 AND c.user_id = $2
|
|
||||||
GROUP BY c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at
|
|
||||||
`, counterID, userID).Scan(
|
|
||||||
&counter.ID, &counter.UserID, &counter.Name, &counter.Description,
|
|
||||||
&counter.CreatedAt, &counter.UpdatedAt, &counter.TotalValue,
|
|
||||||
&counter.TodayValue, &counter.WeekValue, &counter.MonthValue,
|
|
||||||
&counter.EntryCount,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counter"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, counter)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateCounterHandler updates a counter
|
|
||||||
func UpdateCounterHandler(c *gin.Context) {
|
|
||||||
userID := c.GetInt("user_id")
|
|
||||||
counterID, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req UpdateCounterRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var counter Counter
|
|
||||||
err = db.QueryRow(
|
|
||||||
"UPDATE counters SET name = $1, description = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND user_id = $4 RETURNING id, user_id, name, description, created_at, updated_at",
|
|
||||||
req.Name, req.Description, counterID, userID,
|
|
||||||
).Scan(&counter.ID, &counter.UserID, &counter.Name, &counter.Description, &counter.CreatedAt, &counter.UpdatedAt)
|
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update counter"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, counter)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteCounterHandler deletes a counter
|
|
||||||
func DeleteCounterHandler(c *gin.Context) {
|
|
||||||
userID := c.GetInt("user_id")
|
|
||||||
counterID, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := db.Exec("DELETE FROM counters WHERE id = $1 AND user_id = $2", counterID, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete counter"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsAffected, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check deletion"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowsAffected == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Counter deleted successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrementCounterHandler increments/decrements a counter
|
|
||||||
func IncrementCounterHandler(c *gin.Context) {
|
|
||||||
userID := c.GetInt("user_id")
|
|
||||||
counterID, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req IncrementRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify counter belongs to user
|
|
||||||
var exists bool
|
|
||||||
err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)", counterID, userID).Scan(&exists)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert counter entry
|
|
||||||
var entry CounterEntry
|
|
||||||
err = db.QueryRow(
|
|
||||||
"INSERT INTO counter_entries (counter_id, value, date) VALUES ($1, $2, CURRENT_DATE) RETURNING id, counter_id, value, date, created_at",
|
|
||||||
counterID, req.Value,
|
|
||||||
).Scan(&entry.ID, &entry.CounterID, &entry.Value, &entry.Date, &entry.CreatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter entry"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update counter's updated_at timestamp
|
|
||||||
_, err = db.Exec("UPDATE counters SET updated_at = CURRENT_TIMESTAMP WHERE id = $1", counterID)
|
|
||||||
if err != nil {
|
|
||||||
// Log error but don't fail the request
|
|
||||||
fmt.Printf("Warning: Failed to update counter timestamp: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCounterEntriesHandler retrieves entries for a specific counter
|
|
||||||
func GetCounterEntriesHandler(c *gin.Context) {
|
|
||||||
userID := c.GetInt("user_id")
|
|
||||||
counterID, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify counter belongs to user
|
|
||||||
var exists bool
|
|
||||||
err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)", counterID, userID).Scan(&exists)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse date range parameters
|
|
||||||
startDate := c.Query("start_date")
|
|
||||||
endDate := c.Query("end_date")
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT id, counter_id, value, date, created_at
|
|
||||||
FROM counter_entries
|
|
||||||
WHERE counter_id = $1
|
|
||||||
`
|
|
||||||
args := []interface{}{counterID}
|
|
||||||
|
|
||||||
if startDate != "" {
|
|
||||||
query += " AND date >= $2"
|
|
||||||
args = append(args, startDate)
|
|
||||||
if endDate != "" {
|
|
||||||
query += " AND date <= $3"
|
|
||||||
args = append(args, endDate)
|
|
||||||
}
|
|
||||||
} else if endDate != "" {
|
|
||||||
query += " AND date <= $2"
|
|
||||||
args = append(args, endDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY date DESC, created_at DESC"
|
|
||||||
|
|
||||||
rows, err := db.Query(query, args...)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counter entries"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var entries []CounterEntry
|
|
||||||
for rows.Next() {
|
|
||||||
var entry CounterEntry
|
|
||||||
err := rows.Scan(&entry.ID, &entry.CounterID, &entry.Value, &entry.Date, &entry.CreatedAt)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan entry"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCounterStatsHandler retrieves statistics for a counter
|
|
||||||
func GetCounterStatsHandler(c *gin.Context) {
|
|
||||||
userID := c.GetInt("user_id")
|
|
||||||
counterID, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify counter belongs to user
|
|
||||||
var exists bool
|
|
||||||
err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)", counterID, userID).Scan(&exists)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get daily statistics for the last 30 days
|
|
||||||
rows, err := db.Query(`
|
|
||||||
SELECT date, SUM(value) as daily_total
|
|
||||||
FROM counter_entries
|
|
||||||
WHERE counter_id = $1 AND date >= CURRENT_DATE - INTERVAL '30 days'
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date DESC
|
|
||||||
`, counterID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type DailyStat struct {
|
|
||||||
Date time.Time `json:"date"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats []DailyStat
|
|
||||||
for rows.Next() {
|
|
||||||
var stat DailyStat
|
|
||||||
err := rows.Scan(&stat.Date, &stat.Total)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan statistic"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stats = append(stats, stat)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"daily_stats": stats})
|
|
||||||
}
|
|
||||||
2
go.mod
2
go.mod
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/prometheus/client_golang v1.21.0
|
github.com/prometheus/client_golang v1.21.0
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
golang.org/x/crypto v0.31.0
|
golang.org/x/crypto v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +39,6 @@ require (
|
|||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/prometheus/common v0.62.0 // indirect
|
github.com/prometheus/common v0.62.0 // indirect
|
||||||
github.com/prometheus/procfs v0.15.1 // indirect
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
|
|||||||
79
internal/delivery/http/handlers/auth_handler.go
Normal file
79
internal/delivery/http/handlers/auth_handler.go
Normal 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)
|
||||||
|
}
|
||||||
228
internal/delivery/http/handlers/counter_handler.go
Normal file
228
internal/delivery/http/handlers/counter_handler.go
Normal 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})
|
||||||
|
}
|
||||||
57
internal/delivery/http/middleware/auth.go
Normal file
57
internal/delivery/http/middleware/auth.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/delivery/http/middleware/logging.go
Normal file
43
internal/delivery/http/middleware/logging.go
Normal 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 ""
|
||||||
|
})
|
||||||
|
}
|
||||||
21
internal/delivery/http/middleware/metrics.go
Normal file
21
internal/delivery/http/middleware/metrics.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
internal/delivery/http/router.go
Normal file
107
internal/delivery/http/router.go
Normal 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
|
||||||
|
}
|
||||||
60
internal/domain/entities/counter.go
Normal file
60
internal/domain/entities/counter.go
Normal 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
|
||||||
|
}
|
||||||
19
internal/domain/entities/errors.go
Normal file
19
internal/domain/entities/errors.go
Normal 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")
|
||||||
|
)
|
||||||
32
internal/domain/entities/user.go
Normal file
32
internal/domain/entities/user.go
Normal 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 = ""
|
||||||
|
}
|
||||||
20
internal/domain/repositories/counter_repository.go
Normal file
20
internal/domain/repositories/counter_repository.go
Normal 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)
|
||||||
|
}
|
||||||
16
internal/domain/repositories/user_repository.go
Normal file
16
internal/domain/repositories/user_repository.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -63,7 +63,6 @@ func loadEnvironmentFiles() {
|
|||||||
// Get environment first (from system env or default)
|
// Get environment first (from system env or default)
|
||||||
env := getEnvironmentFromSystem()
|
env := getEnvironmentFromSystem()
|
||||||
|
|
||||||
// Use standard log for early initialization (before logger is ready)
|
|
||||||
log.Printf("🔍 Detected environment: %s", env)
|
log.Printf("🔍 Detected environment: %s", env)
|
||||||
|
|
||||||
// Define file loading order (later files override earlier ones)
|
// Define file loading order (later files override earlier ones)
|
||||||
@@ -1,51 +1,56 @@
|
|||||||
package main
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
"counter/internal/infrastructure/config"
|
||||||
|
"counter/internal/infrastructure/logging"
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
var db *sql.DB
|
// Connection manages the database connection
|
||||||
|
type Connection struct {
|
||||||
// InitDB initializes the database connection (legacy function)
|
db *sql.DB
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitDBWithConfig initializes the database connection with configuration
|
// NewConnection creates a new database connection
|
||||||
func InitDBWithConfig(config *Config) error {
|
func NewConnection(cfg *config.Config, logger logging.Logger) (*Connection, error) {
|
||||||
return initDBWithURL(config.DatabaseURL)
|
db, err := sql.Open("postgres", cfg.DatabaseURL)
|
||||||
}
|
|
||||||
|
|
||||||
// initDBWithURL initializes the database connection with a specific URL
|
|
||||||
func initDBWithURL(dbURL string) error {
|
|
||||||
|
|
||||||
var err error
|
|
||||||
db, err = sql.Open("postgres", dbURL)
|
|
||||||
if err != nil {
|
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
|
// Test the connection
|
||||||
if err = db.Ping(); err != nil {
|
if err = db.Ping(); err != nil {
|
||||||
return fmt.Errorf("failed to ping database: %w", err)
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Println("✅ Database connection established successfully")
|
logger.Info("✅ Database connection established successfully")
|
||||||
return nil
|
|
||||||
|
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
|
// CreateTables creates the necessary database tables
|
||||||
func CreateTables() error {
|
func (c *Connection) CreateTables() error {
|
||||||
queries := []string{
|
queries := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS users (
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -76,16 +81,10 @@ func CreateTables() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, query := range queries {
|
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)
|
return fmt.Errorf("failed to execute query: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Println("✅ Database tables created successfully")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDB returns the database connection
|
|
||||||
func GetDB() *sql.DB {
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
283
internal/infrastructure/database/postgres/counter_repository.go
Normal file
283
internal/infrastructure/database/postgres/counter_repository.go
Normal 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
|
||||||
|
}
|
||||||
146
internal/infrastructure/database/postgres/user_repository.go
Normal file
146
internal/infrastructure/database/postgres/user_repository.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package logging
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -6,26 +6,44 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"counter/internal/infrastructure/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Logger is the global logger instance
|
// Logger interface defines the contract for logging
|
||||||
var Logger *logrus.Logger
|
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
|
// InitLogger initializes the structured logger with file output
|
||||||
func InitLogger(config *Config) error {
|
func InitLogger(cfg *config.Config) (Logger, error) {
|
||||||
Logger = logrus.New()
|
logger := logrus.New()
|
||||||
|
|
||||||
// Set log level based on configuration
|
// Set log level based on configuration
|
||||||
level, err := logrus.ParseLevel(config.LogLevel)
|
level, err := logrus.ParseLevel(cfg.LogLevel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
level = logrus.InfoLevel
|
level = logrus.InfoLevel
|
||||||
}
|
}
|
||||||
Logger.SetLevel(level)
|
logger.SetLevel(level)
|
||||||
|
|
||||||
// Set JSON formatter for structured logging
|
// Set JSON formatter for structured logging
|
||||||
Logger.SetFormatter(&logrus.JSONFormatter{
|
logger.SetFormatter(&logrus.JSONFormatter{
|
||||||
TimestampFormat: time.RFC3339,
|
TimestampFormat: time.RFC3339,
|
||||||
FieldMap: logrus.FieldMap{
|
FieldMap: logrus.FieldMap{
|
||||||
logrus.FieldKeyTime: "timestamp",
|
logrus.FieldKeyTime: "timestamp",
|
||||||
@@ -35,59 +53,38 @@ func InitLogger(config *Config) error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Create log directory if it doesn't exist
|
// Create log directory if it doesn't exist
|
||||||
if err := os.MkdirAll(config.LogDir, 0755); err != nil {
|
if err := os.MkdirAll(cfg.LogDir, 0755); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create log file with timestamp
|
// Create log file with timestamp
|
||||||
logFile := filepath.Join(config.LogDir, "app.log")
|
logFile := filepath.Join(cfg.LogDir, "app.log")
|
||||||
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set output to both file and stdout
|
// Set output to both file and stdout
|
||||||
multiWriter := io.MultiWriter(os.Stdout, file)
|
multiWriter := io.MultiWriter(os.Stdout, file)
|
||||||
Logger.SetOutput(multiWriter)
|
logger.SetOutput(multiWriter)
|
||||||
|
|
||||||
// Log initialization with default fields
|
// Log initialization with default fields
|
||||||
Logger.WithFields(logrus.Fields{
|
logger.WithFields(logrus.Fields{
|
||||||
"service": "counter-app",
|
"service": "counter-app",
|
||||||
"environment": string(config.Environment),
|
"environment": string(cfg.Environment),
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
}).Info("Logger initialized successfully")
|
}).Info("Logger initialized successfully")
|
||||||
|
|
||||||
return nil
|
return &LogrusLogger{Logger: logger}, nil
|
||||||
}
|
|
||||||
|
|
||||||
// GetLogger returns the global logger instance
|
|
||||||
func GetLogger() *logrus.Logger {
|
|
||||||
if Logger == nil {
|
|
||||||
// Fallback to standard logger if not initialized
|
|
||||||
logrus.SetFormatter(&logrus.JSONFormatter{
|
|
||||||
TimestampFormat: time.RFC3339,
|
|
||||||
})
|
|
||||||
return logrus.StandardLogger()
|
|
||||||
}
|
|
||||||
return Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLoggerWithDefaults returns a logger entry with default fields
|
|
||||||
func GetLoggerWithDefaults(env Environment) *logrus.Entry {
|
|
||||||
return Logger.WithFields(logrus.Fields{
|
|
||||||
"service": "counter-app",
|
|
||||||
"environment": string(env),
|
|
||||||
"version": "1.0.0",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoggingMiddleware creates a Gin middleware for HTTP request logging
|
// LoggingMiddleware creates a Gin middleware for HTTP request logging
|
||||||
func LoggingMiddleware(config *Config) gin.HandlerFunc {
|
func LoggingMiddleware(logger Logger, cfg *config.Config) gin.HandlerFunc {
|
||||||
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||||
// Create structured log entry with default fields
|
// Create structured log entry with default fields
|
||||||
entry := Logger.WithFields(logrus.Fields{
|
entry := logger.WithFields(logrus.Fields{
|
||||||
"service": "counter-app",
|
"service": "counter-app",
|
||||||
"environment": string(config.Environment),
|
"environment": string(cfg.Environment),
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"method": param.Method,
|
"method": param.Method,
|
||||||
"path": param.Path,
|
"path": param.Path,
|
||||||
103
internal/infrastructure/metrics/prometheus.go
Normal file
103
internal/infrastructure/metrics/prometheus.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
internal/infrastructure/security/jwt.go
Normal file
59
internal/infrastructure/security/jwt.go
Normal 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")
|
||||||
|
}
|
||||||
29
internal/infrastructure/security/password.go
Normal file
29
internal/infrastructure/security/password.go
Normal 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
|
||||||
|
}
|
||||||
141
internal/usecase/auth/service.go
Normal file
141
internal/usecase/auth/service.go
Normal 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
|
||||||
|
}
|
||||||
133
internal/usecase/counter/service.go
Normal file
133
internal/usecase/counter/service.go
Normal 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)
|
||||||
|
}
|
||||||
113
main.go
113
main.go
@@ -1,113 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-contrib/cors"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Load configuration with environment file precedence
|
|
||||||
config := LoadConfig()
|
|
||||||
|
|
||||||
// Initialize structured logger
|
|
||||||
if err := InitLogger(config); err != nil {
|
|
||||||
Logger.Fatal("Failed to initialize logger:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Gin mode based on configuration
|
|
||||||
gin.SetMode(config.GinMode)
|
|
||||||
|
|
||||||
// Initialize JWT with configuration
|
|
||||||
InitJWTWithConfig(config)
|
|
||||||
|
|
||||||
// Initialize database with configuration
|
|
||||||
if err := InitDBWithConfig(config); err != nil {
|
|
||||||
Logger.WithError(err).Fatal("Failed to initialize database")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create tables
|
|
||||||
if err := CreateTables(); err != nil {
|
|
||||||
Logger.WithError(err).Fatal("Failed to create tables")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Prometheus metrics
|
|
||||||
InitMetrics()
|
|
||||||
|
|
||||||
// Start metrics server on separate port
|
|
||||||
StartMetricsServer(config.MetricsPort)
|
|
||||||
|
|
||||||
// Create Gin router
|
|
||||||
r := gin.Default()
|
|
||||||
|
|
||||||
// Configure CORS
|
|
||||||
corsConfig := cors.DefaultConfig()
|
|
||||||
corsConfig.AllowOrigins = []string{"http://localhost:3000", "http://localhost:5173"} // React dev servers
|
|
||||||
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
|
|
||||||
corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"}
|
|
||||||
corsConfig.AllowCredentials = true
|
|
||||||
r.Use(cors.New(corsConfig))
|
|
||||||
|
|
||||||
// Add metrics middleware
|
|
||||||
r.Use(MetricsMiddleware())
|
|
||||||
|
|
||||||
// Add logging middleware
|
|
||||||
r.Use(LoggingMiddleware(config))
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
api := r.Group("/api/v1")
|
|
||||||
{
|
|
||||||
// Authentication routes
|
|
||||||
auth := api.Group("/auth")
|
|
||||||
{
|
|
||||||
auth.POST("/register", RegisterHandler)
|
|
||||||
auth.POST("/login", LoginHandler)
|
|
||||||
auth.GET("/me", AuthMiddleware(), GetCurrentUserHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counter routes (protected)
|
|
||||||
counters := api.Group("/counters")
|
|
||||||
counters.Use(AuthMiddleware())
|
|
||||||
{
|
|
||||||
counters.POST("", CreateCounterHandler)
|
|
||||||
counters.GET("", GetCountersHandler)
|
|
||||||
counters.GET("/:id", GetCounterHandler)
|
|
||||||
counters.PUT("/:id", UpdateCounterHandler)
|
|
||||||
counters.DELETE("/:id", DeleteCounterHandler)
|
|
||||||
counters.POST("/:id/increment", IncrementCounterHandler)
|
|
||||||
counters.GET("/:id/entries", GetCounterEntriesHandler)
|
|
||||||
counters.GET("/:id/stats", GetCounterStatsHandler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve static files (React app)
|
|
||||||
r.Static("/static", "./frontend/build/static")
|
|
||||||
r.StaticFile("/", "./frontend/build/index.html")
|
|
||||||
r.NoRoute(func(c *gin.Context) {
|
|
||||||
c.File("./frontend/build/index.html")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
port := config.Port
|
|
||||||
|
|
||||||
Logger.WithFields(logrus.Fields{
|
|
||||||
"port": port,
|
|
||||||
"metrics_port": config.MetricsPort,
|
|
||||||
"log_dir": config.LogDir,
|
|
||||||
"log_volume": config.LogVolume,
|
|
||||||
}).Info("🚀 Starting Counter Application Server")
|
|
||||||
|
|
||||||
Logger.WithFields(logrus.Fields{
|
|
||||||
"health_url": "http://localhost:" + port + "/health",
|
|
||||||
"api_url": "http://localhost:" + port + "/api/v1",
|
|
||||||
"frontend_url": "http://localhost:" + port + "/",
|
|
||||||
"metrics_url": "http://localhost:" + config.MetricsPort + "/metrics",
|
|
||||||
}).Info("✅ Server is ready and accepting connections")
|
|
||||||
|
|
||||||
Logger.Fatal(r.Run(":" + port))
|
|
||||||
}
|
|
||||||
137
metrics.go
137
metrics.go
@@ -1,137 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// HTTP request metrics
|
|
||||||
httpRequestsTotal = prometheus.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "http_requests_total",
|
|
||||||
Help: "Total number of HTTP requests",
|
|
||||||
},
|
|
||||||
[]string{"method", "endpoint", "status_code"},
|
|
||||||
)
|
|
||||||
|
|
||||||
// HTTP request duration metrics
|
|
||||||
httpRequestDuration = prometheus.NewHistogramVec(
|
|
||||||
prometheus.HistogramOpts{
|
|
||||||
Name: "http_request_duration_seconds",
|
|
||||||
Help: "HTTP request duration in seconds",
|
|
||||||
Buckets: prometheus.DefBuckets,
|
|
||||||
},
|
|
||||||
[]string{"method", "endpoint"},
|
|
||||||
)
|
|
||||||
|
|
||||||
// API endpoint specific metrics
|
|
||||||
apiRequestsTotal = prometheus.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "api_requests_total",
|
|
||||||
Help: "Total number of API requests by endpoint",
|
|
||||||
},
|
|
||||||
[]string{"endpoint", "method"},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Database operation metrics
|
|
||||||
dbOperationsTotal = prometheus.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "db_operations_total",
|
|
||||||
Help: "Total number of database operations",
|
|
||||||
},
|
|
||||||
[]string{"operation", "table"},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Authentication metrics
|
|
||||||
authAttemptsTotal = prometheus.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "auth_attempts_total",
|
|
||||||
Help: "Total number of authentication attempts",
|
|
||||||
},
|
|
||||||
[]string{"type", "status"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// InitMetrics initializes Prometheus metrics
|
|
||||||
func InitMetrics() {
|
|
||||||
// Register all metrics
|
|
||||||
prometheus.MustRegister(httpRequestsTotal)
|
|
||||||
prometheus.MustRegister(httpRequestDuration)
|
|
||||||
prometheus.MustRegister(apiRequestsTotal)
|
|
||||||
prometheus.MustRegister(dbOperationsTotal)
|
|
||||||
prometheus.MustRegister(authAttemptsTotal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartMetricsServer starts the metrics server on a separate port
|
|
||||||
func StartMetricsServer(port string) {
|
|
||||||
// Create a new HTTP server for metrics
|
|
||||||
metricsMux := http.NewServeMux()
|
|
||||||
metricsMux.Handle("/metrics", promhttp.Handler())
|
|
||||||
|
|
||||||
// Add health check for metrics server
|
|
||||||
metricsMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("Metrics server is healthy"))
|
|
||||||
})
|
|
||||||
|
|
||||||
server := &http.Server{
|
|
||||||
Addr: "localhost:" + port,
|
|
||||||
Handler: metricsMux,
|
|
||||||
ReadTimeout: 5 * time.Second,
|
|
||||||
WriteTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
Logger.Printf("📈 Metrics server starting on http://localhost:%s/metrics", port)
|
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
Logger.Printf("❌ Metrics server failed to start: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MetricsMiddleware is a Gin middleware to collect HTTP metrics
|
|
||||||
func MetricsMiddleware() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
start := time.Now()
|
|
||||||
path := c.FullPath()
|
|
||||||
method := c.Request.Method
|
|
||||||
|
|
||||||
// Process request
|
|
||||||
c.Next()
|
|
||||||
|
|
||||||
// Calculate duration
|
|
||||||
duration := time.Since(start).Seconds()
|
|
||||||
statusCode := strconv.Itoa(c.Writer.Status())
|
|
||||||
|
|
||||||
// Record metrics
|
|
||||||
httpRequestsTotal.WithLabelValues(method, path, statusCode).Inc()
|
|
||||||
httpRequestDuration.WithLabelValues(method, path).Observe(duration)
|
|
||||||
|
|
||||||
// Record API-specific metrics for API routes
|
|
||||||
if strings.HasPrefix(c.Request.URL.Path, "/api") {
|
|
||||||
apiRequestsTotal.WithLabelValues(path, method).Inc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordAPICall records a specific API endpoint call
|
|
||||||
func RecordAPICall(endpoint, method string) {
|
|
||||||
apiRequestsTotal.WithLabelValues(endpoint, method).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordDBOperation records a database operation
|
|
||||||
func RecordDBOperation(operation, table string) {
|
|
||||||
dbOperationsTotal.WithLabelValues(operation, table).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordAuthAttempt records an authentication attempt
|
|
||||||
func RecordAuthAttempt(authType, status string) {
|
|
||||||
authAttemptsTotal.WithLabelValues(authType, status).Inc()
|
|
||||||
}
|
|
||||||
92
models.go
92
models.go
@@ -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"`
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user