diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..81a254d --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Local development only. Copy to .env and replace secrets; .env is ignored. +APP_ENV=development +HTTP_ADDR=127.0.0.1:8080 +DATABASE_URL=postgresql://postgres:change-me@localhost:5432/zarinpal?sslmode=disable +REDIS_ADDR=localhost:6379 +REDIS_PASSWORD= +IMPORT_PATH=data/incoming/challenge_data.csv.gz +DEMO_USERNAME=merchant-0001 +DEMO_PASSWORD=replace-with-at-least-12-characters +SESSION_SECRET=replace-with-at-least-32-random-characters +FRONTEND_ORIGIN=http://localhost:5173 +COOKIE_SECURE=false diff --git a/.gitignore b/.gitignore index a1f18dc..feed7d5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ /coverage/ *.test *.out +*.exe /vendor/ .idea/ .vscode/ - diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cb34b15 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: test vet build migrate import seed api worker + +test: + go test ./... +vet: + go vet ./... +build: + go build ./cmd/... +migrate: + go run ./cmd/migrate +import: + go run ./cmd/importer +seed: + go run ./cmd/admin +api: + go run ./cmd/api +worker: + go run ./cmd/worker diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..affafb1 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "log/slog" + "os" + "time" + + "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config" + "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + cfg, err := config.Load() + if err != nil { + logger.Error("configuration failed", "error", err) + os.Exit(1) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + pool, err := database.Open(ctx, cfg.DatabaseURL) + if err != nil { + logger.Error("database connection failed", "error", err) + os.Exit(1) + } + defer pool.Close() + migration, err := os.ReadFile("migrations/000001_mvp.sql") + if err != nil { + logger.Error("reading migration failed", "error", err) + os.Exit(1) + } + if err := database.Migrate(ctx, pool, string(migration)); err != nil { + logger.Error("migration failed", "error", err) + os.Exit(1) + } + logger.Info("migration complete", "version", 1) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9cb11fb --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend + +go 1.24.0 + +require github.com/jackc/pgx/v5 v5.7.5 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..269e2d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cdb954a --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2142764 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..b41fd7c --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..fd09606 --- /dev/null +++ b/internal/database/migrate.go @@ -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 +} diff --git a/migrations/000001_mvp.sql b/migrations/000001_mvp.sql new file mode 100644 index 0000000..5a58a32 --- /dev/null +++ b/migrations/000001_mvp.sql @@ -0,0 +1,93 @@ +CREATE TABLE IF NOT EXISTS schema_migrations ( + version bigint PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() +); +-- statement-break +CREATE TABLE IF NOT EXISTS import_runs ( + id bigserial PRIMARY KEY, + source_sha256 text NOT NULL UNIQUE, + source_path text NOT NULL, + status text NOT NULL CHECK (status IN ('RUNNING','COMPLETED','FAILED')), + source_rows bigint NOT NULL DEFAULT 0, + session_rows bigint NOT NULL DEFAULT 0, + attempt_rows bigint NOT NULL DEFAULT 0, + started_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz +); +-- statement-break +CREATE TABLE IF NOT EXISTS merchants ( + id bigserial PRIMARY KEY, + source_key text NOT NULL UNIQUE, + category_id bigint NOT NULL, + category_title text NOT NULL, + display_name text NOT NULL, + profile_is_synthetic boolean NOT NULL DEFAULT true +); +-- statement-break +CREATE TABLE IF NOT EXISTS terminals ( + id bigserial PRIMARY KEY, + merchant_id bigint NOT NULL REFERENCES merchants(id), + source_key text NOT NULL, + verify_type text NOT NULL, + UNIQUE (merchant_id, source_key) +); +-- statement-break +CREATE TABLE IF NOT EXISTS payment_sessions ( + id bigserial PRIMARY KEY, + import_run_id bigint NOT NULL REFERENCES import_runs(id), + merchant_id bigint NOT NULL REFERENCES merchants(id), + terminal_id bigint NOT NULL REFERENCES terminals(id), + source_key text NOT NULL, + status text NOT NULL, + amount_rial bigint NOT NULL CHECK (amount_rial >= 0), + fee_amount_rial bigint NOT NULL CHECK (fee_amount_rial >= 0), + payer_card_key text, + created_at timestamptz NOT NULL, + expires_at timestamptz, + verified_at timestamptz, + settled_at timestamptz, + UNIQUE (merchant_id, source_key) +); +-- statement-break +CREATE TABLE IF NOT EXISTS payment_attempts ( + id bigserial PRIMARY KEY, + session_id bigint NOT NULL REFERENCES payment_sessions(id) ON DELETE CASCADE, + sequence integer NOT NULL CHECK (sequence > 0), + status text NOT NULL, + created_at timestamptz, + init_time_ms integer, + verify_time_ms integer, + UNIQUE (session_id, sequence) +); +-- statement-break +CREATE INDEX IF NOT EXISTS payment_sessions_merchant_created_idx ON payment_sessions (merchant_id, created_at DESC, id DESC); +-- statement-break +CREATE INDEX IF NOT EXISTS payment_sessions_status_idx ON payment_sessions (merchant_id, status, created_at DESC); +-- statement-break +CREATE INDEX IF NOT EXISTS payment_attempts_session_idx ON payment_attempts (session_id, sequence); +-- statement-break +CREATE TABLE IF NOT EXISTS merchant_users ( + id bigserial PRIMARY KEY, + merchant_id bigint NOT NULL REFERENCES merchants(id), + username text NOT NULL UNIQUE, + password_hash text NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); +-- statement-break +CREATE TABLE IF NOT EXISTS auth_sessions ( + id bigserial PRIMARY KEY, + user_id bigint NOT NULL REFERENCES merchant_users(id), + merchant_id bigint NOT NULL REFERENCES merchants(id), + token_hash bytea NOT NULL UNIQUE, + csrf_token text NOT NULL, + created_at timestamptz NOT NULL, + idle_expires_at timestamptz NOT NULL, + absolute_expires_at timestamptz NOT NULL, + revoked_at timestamptz, + last_seen_at timestamptz NOT NULL +); +-- statement-break +CREATE INDEX IF NOT EXISTS auth_sessions_token_idx ON auth_sessions (token_hash) WHERE revoked_at IS NULL; +-- statement-break +INSERT INTO schema_migrations(version) VALUES (1) ON CONFLICT DO NOTHING;