feat: establish Go service and database foundation

This commit is contained in:
2026-08-20 19:33:38 +03:30
parent 552cae5f9d
commit 961980d18c
11 changed files with 341 additions and 1 deletions

66
internal/config/config.go Normal file
View File

@@ -0,0 +1,66 @@
package config
import (
"errors"
"fmt"
"os"
"strconv"
)
type Config struct {
HTTPAddr string
DatabaseURL string
RedisAddr string
RedisPassword string
ImportPath string
DemoUsername string
DemoPassword string
SessionSecret string
FrontendOrigin string
CookieSecure bool
}
func Load() (Config, error) {
cfg := Config{
HTTPAddr: value("HTTP_ADDR", "127.0.0.1:8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
RedisAddr: value("REDIS_ADDR", "localhost:6379"),
RedisPassword: os.Getenv("REDIS_PASSWORD"),
ImportPath: value("IMPORT_PATH", "data/incoming/challenge_data.csv.gz"),
DemoUsername: value("DEMO_USERNAME", "merchant-0001"),
DemoPassword: os.Getenv("DEMO_PASSWORD"),
SessionSecret: os.Getenv("SESSION_SECRET"),
FrontendOrigin: value("FRONTEND_ORIGIN", "http://localhost:5173"),
}
var err error
cfg.CookieSecure, err = strconv.ParseBool(value("COOKIE_SECURE", "false"))
if err != nil {
return Config{}, fmt.Errorf("parsing cookie secure: %w", err)
}
if cfg.DatabaseURL == "" {
return Config{}, errors.New("config: DATABASE_URL is required")
}
return cfg, nil
}
func (c Config) ValidateAPI() error {
if len(c.SessionSecret) < 32 {
return errors.New("config: SESSION_SECRET must contain at least 32 characters")
}
return nil
}
func (c Config) ValidateSeed() error {
if len(c.DemoPassword) < 12 {
return errors.New("config: DEMO_PASSWORD must contain at least 12 characters")
}
return nil
}
func value(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}

View File

@@ -0,0 +1,18 @@
package config
import "testing"
func TestLoadRequiresDatabaseURL(t *testing.T) {
t.Setenv("DATABASE_URL", "")
if _, err := Load(); err == nil {
t.Fatal("Load() error = nil, want required database url error")
}
}
func TestValidateAPIRequiresLongSecret(t *testing.T) {
t.Parallel()
cfg := Config{SessionSecret: "short"}
if err := cfg.ValidateAPI(); err == nil {
t.Fatal("ValidateAPI() error = nil, want secret length error")
}
}

View File

@@ -0,0 +1,30 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
poolConfig, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parsing database configuration: %w", err)
}
poolConfig.MaxConns = 12
poolConfig.MinConns = 1
poolConfig.MaxConnLifetime = 30 * time.Minute
poolConfig.MaxConnIdleTime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("pinging database: %w", err)
}
return pool, nil
}

View File

@@ -0,0 +1,23 @@
package database
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
// Migrate applies the idempotent local MVP schema. Production migration tooling
// can execute the same reviewed SQL from migrations/.
func Migrate(ctx context.Context, pool *pgxpool.Pool, migration string) error {
for statement := range strings.SplitSeq(migration, "-- statement-break") {
if strings.TrimSpace(statement) == "" {
continue
}
if _, err := pool.Exec(ctx, statement); err != nil {
return fmt.Errorf("applying migration: %w", err)
}
}
return nil
}