This commit is contained in:
225
internal/infrastructure/config/config.go
Normal file
225
internal/infrastructure/config/config.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Environment string
|
||||
|
||||
const (
|
||||
Development Environment = "development"
|
||||
Staging Environment = "staging"
|
||||
Production Environment = "production"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment Environment
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
Port string
|
||||
MetricsPort string
|
||||
GinMode string
|
||||
LogLevel string
|
||||
Debug bool
|
||||
LogDir string
|
||||
LogVolume string
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration with proper environment file precedence
|
||||
func LoadConfig() *Config {
|
||||
// Load environment files in priority order
|
||||
loadEnvironmentFiles()
|
||||
|
||||
// Get environment
|
||||
env := getEnvironment()
|
||||
|
||||
// Load configuration
|
||||
config := &Config{
|
||||
Environment: env,
|
||||
DatabaseURL: getEnv("DATABASE_URL", getDefaultDatabaseURL(env)),
|
||||
JWTSecret: getRequiredEnv("JWT_SECRET"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
MetricsPort: getEnv("METRICS_PORT", "9090"),
|
||||
GinMode: getGinMode(env),
|
||||
LogLevel: getLogLevel(env),
|
||||
Debug: env == Development,
|
||||
LogDir: getEnv("LOG_DIR", "/app/logs"),
|
||||
LogVolume: getEnv("LOG_VOLUME", "counter_logs"),
|
||||
}
|
||||
|
||||
// Log configuration (without sensitive data)
|
||||
logConfig(config)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// loadEnvironmentFiles loads environment files in priority order
|
||||
func loadEnvironmentFiles() {
|
||||
// Get environment first (from system env or default)
|
||||
env := getEnvironmentFromSystem()
|
||||
|
||||
log.Printf("🔍 Detected environment: %s", env)
|
||||
|
||||
// Define file loading order (later files override earlier ones)
|
||||
files := []string{
|
||||
".env", // Base configuration
|
||||
fmt.Sprintf(".env.%s", env), // Environment-specific
|
||||
}
|
||||
|
||||
// Load files in order
|
||||
for _, file := range files {
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
if err := godotenv.Load(file); err != nil {
|
||||
log.Printf("⚠️ Warning: Could not load %s: %v", file, err)
|
||||
} else {
|
||||
log.Printf("✅ Loaded: %s", file)
|
||||
}
|
||||
} else {
|
||||
log.Printf("❌ Not found: %s", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getEnvironmentFromSystem gets environment from system variables only
|
||||
func getEnvironmentFromSystem() string {
|
||||
// Check if ENVIRONMENT is already set
|
||||
if env := os.Getenv("ENVIRONMENT"); env != "" {
|
||||
return strings.ToLower(env)
|
||||
}
|
||||
|
||||
// Fallback detection
|
||||
if ginMode := os.Getenv("GIN_MODE"); ginMode == "release" {
|
||||
return "production"
|
||||
}
|
||||
|
||||
return "development"
|
||||
}
|
||||
|
||||
// getEnvironment gets the current environment
|
||||
func getEnvironment() Environment {
|
||||
env := strings.ToLower(getEnv("ENVIRONMENT", "development"))
|
||||
|
||||
switch env {
|
||||
case "development", "dev":
|
||||
return Development
|
||||
case "staging", "stage":
|
||||
return Staging
|
||||
case "production", "prod":
|
||||
return Production
|
||||
default:
|
||||
log.Printf("⚠️ Unknown environment '%s', defaulting to development", env)
|
||||
return Development
|
||||
}
|
||||
}
|
||||
|
||||
// getGinMode returns the appropriate Gin mode for the environment
|
||||
func getGinMode(env Environment) string {
|
||||
switch env {
|
||||
case Production, Staging:
|
||||
return "release"
|
||||
case Development:
|
||||
return "debug"
|
||||
default:
|
||||
return "debug"
|
||||
}
|
||||
}
|
||||
|
||||
// getLogLevel returns the appropriate log level for the environment
|
||||
func getLogLevel(env Environment) string {
|
||||
switch env {
|
||||
case Production:
|
||||
return "info" // Changed from "warn" to "info" to capture more logs
|
||||
case Staging:
|
||||
return "info"
|
||||
case Development:
|
||||
return "debug"
|
||||
default:
|
||||
return "debug"
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultDatabaseURL returns default database URL for environment
|
||||
func getDefaultDatabaseURL(env Environment) string {
|
||||
switch env {
|
||||
case Development:
|
||||
return "postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
|
||||
case Staging:
|
||||
return "postgres://postgres:password@postgres:5432/counter_db?sslmode=disable"
|
||||
case Production:
|
||||
return "postgres://postgres:password@postgres:5432/counter_db?sslmode=require"
|
||||
default:
|
||||
return "postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv gets environment variable with default value
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getRequiredEnv gets required environment variable
|
||||
func getRequiredEnv(key string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
log.Fatalf("❌ Required environment variable %s is not set", key)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// logConfig logs configuration (without sensitive data)
|
||||
func logConfig(config *Config) {
|
||||
// Use standard log for configuration banner since logger might not be initialized yet
|
||||
log.Printf("")
|
||||
log.Printf("╔══════════════════════════════════════════════════════════════╗")
|
||||
log.Printf("║ COUNTER APPLICATION ║")
|
||||
log.Printf("║ ║")
|
||||
log.Printf("║ 🌍 ENVIRONMENT: %-15s ║", strings.ToUpper(string(config.Environment)))
|
||||
log.Printf("║ 🚀 MODE: %-20s ║", config.GinMode)
|
||||
log.Printf("║ 🔧 DEBUG: %-20s ║", fmt.Sprintf("%t", config.Debug))
|
||||
log.Printf("║ 📊 LOG LEVEL: %-15s ║", config.LogLevel)
|
||||
log.Printf("║ 🌐 PORT: %-20s ║", config.Port)
|
||||
log.Printf("║ 📈 METRICS PORT: %-15s ║", config.MetricsPort)
|
||||
log.Printf("║ 📝 LOG DIR: %-20s ║", config.LogDir)
|
||||
log.Printf("║ 📦 LOG VOLUME: %-18s ║", config.LogVolume)
|
||||
log.Printf("║ ║")
|
||||
log.Printf("║ 📁 Configuration Files Loaded: ║")
|
||||
log.Printf("║ • .env (base configuration) ║")
|
||||
log.Printf("║ • .env.%s (environment-specific) ║", config.Environment)
|
||||
log.Printf("║ ║")
|
||||
log.Printf("║ 🔐 Security: ║")
|
||||
log.Printf("║ • Database: %s ║", maskDatabaseURL(config.DatabaseURL))
|
||||
log.Printf("║ • JWT Secret: %s ║", maskSecret(config.JWTSecret))
|
||||
log.Printf("║ ║")
|
||||
log.Printf("╚══════════════════════════════════════════════════════════════╝")
|
||||
log.Printf("")
|
||||
}
|
||||
|
||||
// maskDatabaseURL masks sensitive parts of database URL
|
||||
func maskDatabaseURL(url string) string {
|
||||
// Simple masking - replace password with ***
|
||||
if strings.Contains(url, "://") {
|
||||
parts := strings.Split(url, "://")
|
||||
if len(parts) == 2 {
|
||||
// Replace password in connection string
|
||||
masked := strings.Replace(parts[1], ":", ":***@", 1)
|
||||
return parts[0] + "://" + masked
|
||||
}
|
||||
}
|
||||
return "***"
|
||||
}
|
||||
|
||||
// maskSecret masks JWT secret for logging
|
||||
func maskSecret(secret string) string {
|
||||
if len(secret) <= 8 {
|
||||
return "***"
|
||||
}
|
||||
return secret[:4] + "***" + secret[len(secret)-4:]
|
||||
}
|
||||
90
internal/infrastructure/database/postgres/connection.go
Normal file
90
internal/infrastructure/database/postgres/connection.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"counter/internal/infrastructure/config"
|
||||
"counter/internal/infrastructure/logging"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Connection manages the database connection
|
||||
type Connection struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewConnection creates a new database connection
|
||||
func NewConnection(cfg *config.Config, logger logging.Logger) (*Connection, error) {
|
||||
db, err := sql.Open("postgres", cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
if err = db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("✅ Database connection established successfully")
|
||||
|
||||
conn := &Connection{db: db}
|
||||
|
||||
// Create tables
|
||||
if err := conn.CreateTables(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create tables: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// GetDB returns the database connection
|
||||
func (c *Connection) GetDB() *sql.DB {
|
||||
return c.db
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (c *Connection) Close() error {
|
||||
return c.db.Close()
|
||||
}
|
||||
|
||||
// CreateTables creates the necessary database tables
|
||||
func (c *Connection) CreateTables() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS counters (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS counter_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
counter_id INTEGER REFERENCES counters(id) ON DELETE CASCADE,
|
||||
value INTEGER NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_counters_user_id ON counters(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_counter_entries_counter_id ON counter_entries(counter_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_counter_entries_date ON counter_entries(date)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := c.db.Exec(query); err != nil {
|
||||
return fmt.Errorf("failed to execute query: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
111
internal/infrastructure/logging/logger.go
Normal file
111
internal/infrastructure/logging/logger.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"counter/internal/infrastructure/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Logger interface defines the contract for logging
|
||||
type Logger interface {
|
||||
Info(args ...interface{})
|
||||
Infof(format string, args ...interface{})
|
||||
Warn(args ...interface{})
|
||||
Warnf(format string, args ...interface{})
|
||||
Error(args ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
Fatal(args ...interface{})
|
||||
Fatalf(format string, args ...interface{})
|
||||
WithFields(fields logrus.Fields) *logrus.Entry
|
||||
WithError(err error) *logrus.Entry
|
||||
}
|
||||
|
||||
// LogrusLogger implements the Logger interface using logrus
|
||||
type LogrusLogger struct {
|
||||
*logrus.Logger
|
||||
}
|
||||
|
||||
// InitLogger initializes the structured logger with file output
|
||||
func InitLogger(cfg *config.Config) (Logger, error) {
|
||||
logger := logrus.New()
|
||||
|
||||
// Set log level based on configuration
|
||||
level, err := logrus.ParseLevel(cfg.LogLevel)
|
||||
if err != nil {
|
||||
level = logrus.InfoLevel
|
||||
}
|
||||
logger.SetLevel(level)
|
||||
|
||||
// Set JSON formatter for structured logging
|
||||
logger.SetFormatter(&logrus.JSONFormatter{
|
||||
TimestampFormat: time.RFC3339,
|
||||
FieldMap: logrus.FieldMap{
|
||||
logrus.FieldKeyTime: "timestamp",
|
||||
logrus.FieldKeyLevel: "level",
|
||||
logrus.FieldKeyMsg: "message",
|
||||
},
|
||||
})
|
||||
|
||||
// Create log directory if it doesn't exist
|
||||
if err := os.MkdirAll(cfg.LogDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create log file with timestamp
|
||||
logFile := filepath.Join(cfg.LogDir, "app.log")
|
||||
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set output to both file and stdout
|
||||
multiWriter := io.MultiWriter(os.Stdout, file)
|
||||
logger.SetOutput(multiWriter)
|
||||
|
||||
// Log initialization with default fields
|
||||
logger.WithFields(logrus.Fields{
|
||||
"service": "counter-app",
|
||||
"environment": string(cfg.Environment),
|
||||
"version": "1.0.0",
|
||||
}).Info("Logger initialized successfully")
|
||||
|
||||
return &LogrusLogger{Logger: logger}, nil
|
||||
}
|
||||
|
||||
// LoggingMiddleware creates a Gin middleware for HTTP request logging
|
||||
func LoggingMiddleware(logger Logger, cfg *config.Config) gin.HandlerFunc {
|
||||
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||
// Create structured log entry with default fields
|
||||
entry := logger.WithFields(logrus.Fields{
|
||||
"service": "counter-app",
|
||||
"environment": string(cfg.Environment),
|
||||
"version": "1.0.0",
|
||||
"method": param.Method,
|
||||
"path": param.Path,
|
||||
"status": param.StatusCode,
|
||||
"latency": param.Latency.String(),
|
||||
"client_ip": param.ClientIP,
|
||||
"user_agent": param.Request.UserAgent(),
|
||||
"timestamp": param.TimeStamp.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
// Set log level based on status code
|
||||
switch {
|
||||
case param.StatusCode >= 500:
|
||||
entry.Error("HTTP Request")
|
||||
case param.StatusCode >= 400:
|
||||
entry.Warn("HTTP Request")
|
||||
default:
|
||||
entry.Info("HTTP Request")
|
||||
}
|
||||
|
||||
// Return empty string since we're handling logging ourselves
|
||||
return ""
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user