diff --git a/.air/.air.productapp.toml b/.air/.air.productapp.toml new file mode 100644 index 00000000..664dc09f --- /dev/null +++ b/.air/.air.productapp.toml @@ -0,0 +1,34 @@ +root = "." +tmp_dir = "cmd/productapp/temp" + +[build] + bin = "/entrypoint.sh" + args_bin = [] + cmd = "go build -mod=mod -buildvcs=false -o ./cmd/productapp/temp/main ./cmd/productapp/" + delay = 1000 + exclude_dir = ["vendor", "cmd/productapp/temp"] + exclude_file = [] + exclude_regex = [] + exclude_unchanged = false + follow_symlink = false + include_dir = [] + include_ext = ["go"] + kill_delay = "0s" + log = "build-errors.log" + send_interrupt = false + stop_on_error = true + poll = true + poll_interval = 1000 + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + time = false + +[misc] + clean_on_exit = false diff --git a/.gitignore b/.gitignore index 5af29d53..05ff05ac 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,6 @@ tmp logs/ mise.log -curl \ No newline at end of file +curl + +cmd/**/temp/main \ No newline at end of file diff --git a/Makefile b/Makefile index 43ec50c5..6e126190 100644 --- a/Makefile +++ b/Makefile @@ -1,54 +1,64 @@ -# TODO: add commands for build and run in dev/produciton mode +# --- Variables --- +BINARY_NAME ?= niki +BUILD_DIR ?= bin -ROOT=$(realpath $(dir $(lastword $(MAKEFILE_LIST)))) +# ==================================================================================== +# General Go Commands +# ==================================================================================== +.PHONY: start test build clean mod-tidy lint install-linter help format swagger watch -.PHONY: help confirm lint test format build run docker swagger watch migrate/status migrate/new migrate/up migrate/down - -confirm: - @echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ] - -lint: - which golangci-lint || (go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.0) - golangci-lint run --config=$(ROOT)/.golangci.yml $(ROOT)/... +start: build + $(BUILD_DIR)/$(BINARY_NAME) test: go test -v ./... +build: + @mkdir -p $(BUILD_DIR) + go build -o $(BUILD_DIR)/$(BINARY_NAME) main.go + +clean: + rm -rf $(BUILD_DIR)/ + +mod-tidy: + go mod tidy + +lint: + golangci-lint run + +install-linter: + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + format: @which gofumpt || (go install mvdan.cc/gofumpt@latest) - @gofumpt -l -w $(ROOT) + @gofumpt -l -w . @which gci || (go install github.com/daixiang0/gci@latest) - @gci write $(ROOT) --skip-generated --skip-vendor - @which golangci-lint || (go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.0) + @gci write . --skip-generated --skip-vendor @golangci-lint run --fix -build: - go build -o niki main.go - -run: - go run main.go --migrate - -docker: - sudo docker compose up -d - swagger: swag init # Live Reload watch: @if command -v CompileDaemon > /dev/null; then \ - CompileDaemon -exclude-dir=.git -exclude=".#*" -command="./niki"; \ + CompileDaemon -exclude-dir=.git -exclude=".#*" -command="./$(BUILD_DIR)/$(BINARY_NAME)"; \ else \ - read -p "Go's 'CompileDaemon' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ + read -p "Go's 'CompileDaemon' is not installed. Do you want to install it? [Y/n] " choice; \ if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ go install github.com/githubnemo/CompileDaemon@latest; \ - CompileDaemon -exclude-dir=.git -exclude=".#*" -command="./niki"; \ + CompileDaemon -exclude-dir=.git -exclude=".#*" -command="./$(BUILD_DIR)/$(BINARY_NAME)"; \ else \ echo "You chose not to install CompileDaemon. Exiting..."; \ exit 1; \ fi; \ fi +# ==================================================================================== +# Database Migration Commands (legacy niki-core) +# ==================================================================================== +.PHONY: migrate/status migrate/new migrate/up migrate/down + migrate/status: @sql-migrate status -env="production" -config=repository/mysql/dbconfig.yml @@ -63,4 +73,68 @@ migrate/up: confirm migrate/down: confirm @echo 'Tearing down last migration...' - @sql-migrate down -env="production" -config=repository/mysql/dbconfig.yml -limit=1 \ No newline at end of file + @sql-migrate down -env="production" -config=repository/mysql/dbconfig.yml -limit=1 + +confirm: + @echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ] + +# ==================================================================================== +# Productapp Service Commands +# ==================================================================================== +.PHONY: productapp-up productapp-up-logs productapp-rebuild productapp-watch + +PRODUCTAPP_ENV = deploy/productapp/development/.env +PRODUCTAPP_COMPOSE = deploy/productapp/development/docker-compose.yml + +productapp-up: + @echo "Building and starting productapp full stack..." + docker compose --env-file $(PRODUCTAPP_ENV) -f $(PRODUCTAPP_COMPOSE) up --build -d + @echo "productapp stack is up." + +productapp-down: + @echo "Building and starting productapp full stack..." + docker compose --env-file $(PRODUCTAPP_ENV) -f $(PRODUCTAPP_COMPOSE) down + @echo "productapp stack is up." + +productapp-up-logs: + @echo "Building and starting productapp full stack..." + docker compose --env-file $(PRODUCTAPP_ENV) -f $(PRODUCTAPP_COMPOSE) up --build + +# ==================================================================================== +# Docker Commands (legacy) +# ==================================================================================== +.PHONY: docker + +docker: + docker compose up -d + +# ==================================================================================== +# Help Target +# ==================================================================================== +help: + @echo "Available targets:" + @echo "" + @echo "General Go Commands:" + @echo " start - Build and run niki-core locally" + @echo " test - Run tests" + @echo " build - Compile binary" + @echo " clean - Remove build artifacts" + @echo " mod-tidy - Clean up dependencies" + @echo " format - Format code (gofumpt + gci + lint fix)" + @echo " lint - Run linters" + @echo " install-linter - Install golangci-lint" + @echo " swagger - Generate swagger docs" + @echo " watch - Live reload with CompileDaemon" + @echo "" + @echo "Database Migration (niki-core):" + @echo " migrate/status - Show migration status" + @echo " migrate/new - Create new migration (name=)" + @echo " migrate/up - Run migrations up" + @echo " migrate/down - Rollback last migration" + @echo "" + @echo "Productapp Service:" + @echo " productapp-up - Build and start full stack in background" + @echo " productapp-up-logs - Build and start full stack with logs" + @echo "" + @echo "Docker (legacy):" + @echo " docker - Start docker-compose services" diff --git a/cmd/productapp/command/serve.go b/cmd/productapp/command/serve.go index 6f60c82f..9bcb2775 100644 --- a/cmd/productapp/command/serve.go +++ b/cmd/productapp/command/serve.go @@ -1,13 +1,16 @@ package command import ( - "fmt" + "context" "log" - "net/http" "os" "os/signal" + "strconv" "syscall" + "time" + "git.gocasts.ir/ebhomengo/niki/productapp" + "git.gocasts.ir/ebhomengo/niki/repository/mysql" "github.com/spf13/cobra" ) @@ -25,27 +28,53 @@ var serveCmd = &cobra.Command{ func serve() { log.Println("Product Service Starting...") - // TODO: Initialize database connection - // TODO: Initialize service dependencies - // TODO: Setup HTTP server with routes + cfg := productapp.Config{ + HTTPServer: productapp.HTTPServerConfig{ + Port: getEnvInt("HTTP_PORT", 8080), + }, + Database: mysql.Config{ + Username: getEnv("DB_USERNAME", "root"), + Password: getEnv("DB_PASSWORD", ""), + Port: getEnvInt("DB_PORT", 3306), + Host: getEnv("DB_HOST", "localhost"), + DBName: getEnv("DB_NAME", "niki_db"), + }, + } + + if p, err := strconv.Atoi(port); err == nil && p > 0 { + cfg.HTTPServer.Port = p + } + + app := productapp.Setup(cfg) - // Setup graceful shutdown go func() { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh - log.Println("Shutting down Product Service gracefully...") - os.Exit(0) + app.Start() }() - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Product Service OK!") - }) + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit - log.Printf("Product Service listening on port %s", port) - if err := http.ListenAndServe(":"+port, nil); err != nil { - log.Fatalf("Failed to start server: %v", err) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := app.Stop(ctx); err != nil { + log.Fatalf("Server shutdown error: %v", err) } + + log.Println("Product Service stopped.") +} + +func getEnvInt(key string, defaultValue int) int { + val := os.Getenv(key) + if val == "" { + return defaultValue + } + n, err := strconv.Atoi(val) + if err != nil { + return defaultValue + } + return n } func init() { diff --git a/deploy/productapp/development/Dockerfile b/deploy/productapp/development/Dockerfile new file mode 100644 index 00000000..2d7e236e --- /dev/null +++ b/deploy/productapp/development/Dockerfile @@ -0,0 +1,19 @@ +ARG GO_IMAGE_NAME +ARG GO_IMAGE_VERSION + +FROM ${GO_IMAGE_NAME}:${GO_IMAGE_VERSION} +ENV GOPROXY=https://package-mirror.liara.ir/repository/go/ +ENV GOSUMDB=off + +WORKDIR /home/app + +COPY go.mod go.sum ./ +RUN go mod download + +RUN go install github.com/air-verse/air@latest + +RUN printf '#!/bin/sh\n./cmd/productapp/temp/main migrate --up\nexec ./cmd/productapp/temp/main serve\n' > /entrypoint.sh && chmod +x /entrypoint.sh + +COPY . . + +CMD ["air", "-c", "/home/app/.air/.air.productapp.toml"] \ No newline at end of file diff --git a/deploy/productapp/development/docker-compose.no-service.yml b/deploy/productapp/development/docker-compose.no-service.yml new file mode 100644 index 00000000..7b69ee94 --- /dev/null +++ b/deploy/productapp/development/docker-compose.no-service.yml @@ -0,0 +1,20 @@ +services: + productapp-mysql: + image: mirror2.chabokan.net/mysql:8.0 + container_name: productapp-mysql + restart: always + ports: + - "3307:3306" + volumes: + - productapp-mysql-data:/var/lib/mysql + environment: + MYSQL_DATABASE: niki_db + MYSQL_ROOT_PASSWORD: secret + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + productapp-mysql-data: diff --git a/deploy/productapp/development/docker-compose.yml b/deploy/productapp/development/docker-compose.yml new file mode 100644 index 00000000..906b42e2 --- /dev/null +++ b/deploy/productapp/development/docker-compose.yml @@ -0,0 +1,43 @@ +services: + productapp-app: + build: + context: ../../.. + dockerfile: deploy/productapp/development/Dockerfile + args: + GO_IMAGE_NAME: ${GO_IMAGE_NAME} + GO_IMAGE_VERSION: ${GO_IMAGE_VERSION} + container_name: productapp-app + ports: + - "8080:8080" + volumes: + - ../../..:/home/app + environment: + DB_HOST: productapp-mysql + DB_USERNAME: root + DB_PASSWORD: secret + DB_NAME: niki_db + MIGRATION_PATH: /home/app/productapp/repository/migrations + depends_on: + productapp-mysql: + condition: service_healthy + restart: unless-stopped + + productapp-mysql: + image: mirror2.chabokan.net/mysql:8.0 + container_name: productapp-mysql + restart: always + ports: + - "3307:3306" + volumes: + - productapp-mysql-data:/var/lib/mysql + environment: + MYSQL_DATABASE: niki_db + MYSQL_ROOT_PASSWORD: secret + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + productapp-mysql-data: diff --git a/deploy/wallet/development/config.yml b/deploy/wallet/development/config.yml new file mode 100644 index 00000000..e69de29b diff --git a/deploy/wallet/development/docker-compose.yml b/deploy/wallet/development/docker-compose.yml new file mode 100644 index 00000000..e69de29b diff --git a/deploy/wallet/production/.gitkeep b/deploy/wallet/production/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/deploy/wallet/stage/.gitkeep b/deploy/wallet/stage/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/domain/gamification/repository/mysql/db.go b/domain/gamification/repository/mysql/db.go new file mode 100644 index 00000000..0082acb0 --- /dev/null +++ b/domain/gamification/repository/mysql/db.go @@ -0,0 +1,11 @@ +package mysql + +import "git.gocasts.ir/ebhomengo/niki/repository/mysql" + +type DB struct { + conn *mysql.DB +} + +func New(db *mysql.DB) *DB { + return &DB{conn: db} +} diff --git a/domain/gamification/repository/mysql/gamification.go b/domain/gamification/repository/mysql/gamification.go new file mode 100644 index 00000000..b0843023 --- /dev/null +++ b/domain/gamification/repository/mysql/gamification.go @@ -0,0 +1 @@ +package mysql diff --git a/domain/gamification/service/gamification.go b/domain/gamification/service/gamification.go new file mode 100644 index 00000000..bcfe155b --- /dev/null +++ b/domain/gamification/service/gamification.go @@ -0,0 +1,8 @@ +package service + +type Service struct { + repo Repo +} + +type Repo interface { +} diff --git a/domain/wallet/entity/transaction.go b/domain/wallet/entity/transaction.go new file mode 100644 index 00000000..09c98888 --- /dev/null +++ b/domain/wallet/entity/transaction.go @@ -0,0 +1,28 @@ +package entity + +import "time" + +type Transaction struct { + ID uint64 + UserID uint64 + Amount float64 + Currency Currency + ActionType TransactionType + Timestamp time.Time +} + +type TransactionType string + +const ( + TransactionTypeDeposit TransactionType = "deposit" + TransactionTypeWithdraw TransactionType = "withdraw" + TransactionTypeRefund TransactionType = "refund" + TransactionTypeDonate TransactionType = "donate" +) + +type Currency string + +const ( + IRR Currency = "IRR" + USD Currency = "USD" +) diff --git a/domain/wallet/entity/wallet.go b/domain/wallet/entity/wallet.go new file mode 100644 index 00000000..175a638d --- /dev/null +++ b/domain/wallet/entity/wallet.go @@ -0,0 +1,23 @@ +package entity + +import "time" + +type Wallet struct { + ID uint64 + UserID uint64 // user unique ID + Balance float64 + Currency Currency + UpdatedAt time.Time + Status WalletStatus // "active", "frozen", "closed" +} + +type WalletStatus string + +const ( + Frozen WalletStatus = "frozen" // when need to check , approve ,validate , solve sth (but deposit is possible) + Active WalletStatus = "active" // when everything is ok + + // ?? + // Closed WalletStatus = "closed" // when need to check , approve ,validate , solve sth (exp : security problem) + +) diff --git a/domain/wallet/param/create_transaction.go b/domain/wallet/param/create_transaction.go new file mode 100644 index 00000000..1341ed46 --- /dev/null +++ b/domain/wallet/param/create_transaction.go @@ -0,0 +1,15 @@ +package param + +import ( + "git.gocasts.ir/ebhomengo/niki/domain/wallet/entity" +) + +type CreateTransactionRequest struct { + UserID uint64 `json:"user_id"` + Amount float64 `json:"amount"` + Currency entity.Currency `json:"currency"` + ActionType entity.TransactionType `json:"action_type"` +} + +type InsertTransactionResponse struct { +} diff --git a/domain/wallet/param/transaction_history.go b/domain/wallet/param/transaction_history.go new file mode 100644 index 00000000..fe720528 --- /dev/null +++ b/domain/wallet/param/transaction_history.go @@ -0,0 +1,24 @@ +package param + +import ( + "time" + + "git.gocasts.ir/ebhomengo/niki/domain/wallet/entity" +) + +type TransactionRequest struct { + UserID uint64 `json:"user_id"` +} + +type TransactionResponse struct { + Transaction []TransactionInfo `json:"transaction"` +} + +type TransactionInfo struct { + ID uint64 `json:"id"` + UserID uint64 `json:"user_id"` + Amount float64 `json:"amount"` + Currency entity.Currency `json:"currency"` + ActionType entity.TransactionType `json:"action_type"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/domain/wallet/param/wallet_info.go b/domain/wallet/param/wallet_info.go new file mode 100644 index 00000000..c71962a5 --- /dev/null +++ b/domain/wallet/param/wallet_info.go @@ -0,0 +1,21 @@ +package param + +import ( + "time" + + "git.gocasts.ir/ebhomengo/niki/domain/wallet/entity" +) + +type WalletRequest struct { + UserID uint64 `json:"user_id"` +} + +type WalletResponse struct { + Wallet WalletInfo `json:"wallet"` +} + +type WalletInfo struct { + Balance float64 `json:"balance"` + UpdatedAt time.Time `json:"updated_at"` + Status entity.WalletStatus `json:"status"` +} diff --git a/domain/wallet/repository/postgres/db.go b/domain/wallet/repository/postgres/db.go new file mode 100644 index 00000000..787efa54 --- /dev/null +++ b/domain/wallet/repository/postgres/db.go @@ -0,0 +1,11 @@ +package postgres + +import "git.gocasts.ir/ebhomengo/niki/pkg/database/postgres" + +type DB struct { + conn *postgres.DB +} + +func New(conn *postgres.DB) *DB { + return &DB{conn: conn} +} diff --git a/domain/wallet/repository/postgres/dbconfig.yml b/domain/wallet/repository/postgres/dbconfig.yml new file mode 100644 index 00000000..af051ab0 --- /dev/null +++ b/domain/wallet/repository/postgres/dbconfig.yml @@ -0,0 +1,5 @@ +production: + dialect: postgres + datasource: "host=127.0.0.1 port=5432 user=wallet password=wallet2123 dbname=wallet_db sslmode=disable" + dir: domain/wallet/repository/postgres/migrations + table: wallet_migrationsns \ No newline at end of file diff --git a/domain/wallet/service/create_transaction.go b/domain/wallet/service/create_transaction.go new file mode 100644 index 00000000..cfd98ac6 --- /dev/null +++ b/domain/wallet/service/create_transaction.go @@ -0,0 +1,31 @@ +package service + +import ( + "context" + "time" + + "git.gocasts.ir/ebhomengo/niki/domain/wallet/entity" + "git.gocasts.ir/ebhomengo/niki/domain/wallet/param" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" +) + +func (s Service) CreateTransaction(ctx context.Context, request param.CreateTransactionRequest) (param.InsertTransactionResponse, error) { + + const op = richerror.Op("wallet.service.CreateTransaction") + + transaction := entity.Transaction{ + ID: 0, + UserID: request.UserID, + Amount: request.Amount, + Currency: request.Currency, + ActionType: request.ActionType, + Timestamp: time.Now(), + } + err := s.repo.InsertTransaction(ctx, transaction) + if err != nil { + return param.InsertTransactionResponse{}, err + } + + return param.InsertTransactionResponse{}, nil + +} diff --git a/domain/wallet/service/service.go b/domain/wallet/service/service.go new file mode 100644 index 00000000..3f949184 --- /dev/null +++ b/domain/wallet/service/service.go @@ -0,0 +1,27 @@ +package service + +import ( + "context" + + "git.gocasts.ir/ebhomengo/niki/domain/wallet/entity" +) + +type Repository interface { + GetTransactionListByUserID(ctx context.Context, UserID uint64) ([]entity.Transaction, error) + GetWalletByUserID(ctx context.Context, UserID uint64) (entity.Wallet, error) + InsertTransaction(ctx context.Context, transaction entity.Transaction) error +} + +type Config struct { +} + +type Service struct { + repo Repository + cfg Config +} + +func New(repo Repository, cfg Config) Service { + + return Service{repo: repo, cfg: cfg} + +} diff --git a/domain/wallet/service/transaction_history.go b/domain/wallet/service/transaction_history.go new file mode 100644 index 00000000..941b8da5 --- /dev/null +++ b/domain/wallet/service/transaction_history.go @@ -0,0 +1,39 @@ +package service + +import ( + "context" + + "git.gocasts.ir/ebhomengo/niki/domain/wallet/entity" + "git.gocasts.ir/ebhomengo/niki/domain/wallet/param" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" +) + +func (s Service) GetUserTransactionHistory(ctx context.Context, request param.TransactionRequest) (param.TransactionResponse, error) { + const op = richerror.Op("wallet.service.GetUserTransactionHistory") + + transactionList, err := s.repo.GetTransactionListByUserID(ctx, request.UserID) + + if err != nil { + return param.TransactionResponse{}, err + } + + return param.TransactionResponse{Transaction: transactionEntityToTransactionInfo(transactionList)}, nil + +} + +func transactionEntityToTransactionInfo(TransactionList []entity.Transaction) []param.TransactionInfo { + transactionInfoList := make([]param.TransactionInfo, len(TransactionList)) + for i, transaction := range TransactionList { + transactionInfoList[i] = param.TransactionInfo{ + ID: transaction.ID, + UserID: transaction.UserID, + Amount: transaction.Amount, + Currency: transaction.Currency, + ActionType: transaction.ActionType, + Timestamp: transaction.Timestamp, + } + } + + return transactionInfoList + +} diff --git a/domain/wallet/service/user_wallet.go b/domain/wallet/service/user_wallet.go new file mode 100644 index 00000000..12ac49b7 --- /dev/null +++ b/domain/wallet/service/user_wallet.go @@ -0,0 +1,27 @@ +package service + +import ( + "context" + + "git.gocasts.ir/ebhomengo/niki/domain/wallet/param" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" +) + +func (s Service) GetUserWallet(ctx context.Context, request param.WalletRequest) (param.WalletResponse, error) { + const op = richerror.Op("wallet.service.GetUserWallet") + + wallet, err := s.repo.GetWalletByUserID(ctx, request.UserID) + + if err != nil { + return param.WalletResponse{}, err + } + + return param.WalletResponse{ + Wallet: param.WalletInfo{ + Balance: wallet.Balance, + UpdatedAt: wallet.UpdatedAt, + Status: wallet.Status, + }, + }, nil + +} diff --git a/gamificationapp/app.go b/gamificationapp/app.go new file mode 100644 index 00000000..651d6964 --- /dev/null +++ b/gamificationapp/app.go @@ -0,0 +1,11 @@ +package gamificationapp + +type Application struct { + Config Config +} + +func setUp(cnf Config) *Application { + return &Application{ + cnf, + } +} diff --git a/gamificationapp/config.go b/gamificationapp/config.go new file mode 100644 index 00000000..3fc50ebb --- /dev/null +++ b/gamificationapp/config.go @@ -0,0 +1,4 @@ +package gamificationapp + +type Config struct { +} diff --git a/gamificationapp/delivery/http/gamification/handler.go b/gamificationapp/delivery/http/gamification/handler.go new file mode 100644 index 00000000..f01b3ad6 --- /dev/null +++ b/gamificationapp/delivery/http/gamification/handler.go @@ -0,0 +1,15 @@ +package gamification + +import ( + gamification "git.gocasts.ir/ebhomengo/niki/domain/gamification/service" +) + +type Handler struct { + GamificationSvc gamification.Service +} + +func New(gamificationSvc gamification.Service) *Handler { + return &Handler{ + GamificationSvc: gamificationSvc, + } +} diff --git a/gamificationapp/delivery/http/gamification/route.go b/gamificationapp/delivery/http/gamification/route.go new file mode 100644 index 00000000..dbb139c5 --- /dev/null +++ b/gamificationapp/delivery/http/gamification/route.go @@ -0,0 +1,7 @@ +package gamification + +import "github.com/labstack/echo/v4" + +func (h Handler) SetRoutes(e *echo.Echo) { + +} diff --git a/gamificationapp/delivery/http/server.go b/gamificationapp/delivery/http/server.go new file mode 100644 index 00000000..54117707 --- /dev/null +++ b/gamificationapp/delivery/http/server.go @@ -0,0 +1,13 @@ +package http + +import "git.gocasts.ir/ebhomengo/niki/gamificationapp/delivery/http/gamification" + +type Server struct { + gamificationHandler *gamification.Handler +} + +func New(handler gamification.Handler) *Server { + return &Server{ + gamificationHandler: &handler, + } +} diff --git a/pkg/database/postgres/db.go b/pkg/database/postgres/db.go new file mode 100644 index 00000000..b843be3d --- /dev/null +++ b/pkg/database/postgres/db.go @@ -0,0 +1,97 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "sync" + "time" + + querier "git.gocasts.ir/ebhomengo/niki/pkg/query_transaction/sql" + _ "github.com/jackc/pgx/v5/stdlib" +) + +type Config struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + User string `koanf:"user"` + Password string `koanf:"password"` + DbName string `koanf:"dbName"` + SSLMode string `koanf:"sslMode"` + MaxIdleConn int `koanf:"maxIdleConns"` + MaxOpenConn int `koanf:"maxOpenConns"` + ConnMaxLifetime int `koanf:"connMaxLifetime"` + PathOfMigrations string `koanf:"pathOfMigrations"` +} + +type DB struct { + config Config + db *querier.SQLDB + mu sync.Mutex + statements map[statementKey]*sql.Stmt +} + +func (db *DB) Conn() *querier.SQLDB { + return db.db +} + +func New(config Config) *DB { + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + config.Host, config.Port, config.User, config.Password, config.DbName, config.SSLMode) + + db, err := sql.Open("pgx", dsn) + if err != nil { + panic(fmt.Errorf("can't open postgres db: %w", err)) + } + + maxIdle := config.MaxIdleConn + + maxOpen := config.MaxOpenConn + + lifetime := time.Duration(config.ConnMaxLifetime) * time.Second + + db.SetMaxIdleConns(maxIdle) + db.SetMaxOpenConns(maxOpen) + db.SetConnMaxLifetime(lifetime) + + return &DB{ + config: config, + db: &querier.SQLDB{DB: db}, + statements: make(map[statementKey]*sql.Stmt), + } +} + +func (db *DB) PrepareStatement(ctx context.Context, key statementKey, query string) (*sql.Stmt, error) { + db.mu.Lock() + defer db.mu.Unlock() + + if stmt, ok := db.statements[key]; ok { + return stmt, nil + } + + stmt, err := db.db.PrepareContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("prepare statement %q: %w", key, err) + } + db.statements[key] = stmt + + return stmt, nil +} + +func (db *DB) CloseStatements() error { + db.mu.Lock() + defer db.mu.Unlock() + + var lastErr error + for key, stmt := range db.statements { + if err := stmt.Close(); err != nil { + lastErr = err + } + delete(db.statements, key) + } + return lastErr +} + +func (db *DB) Close() error { + return db.db.DB.Close() +} diff --git a/pkg/database/postgres/migrator/migrator.go b/pkg/database/postgres/migrator/migrator.go new file mode 100644 index 00000000..0cbadd99 --- /dev/null +++ b/pkg/database/postgres/migrator/migrator.go @@ -0,0 +1 @@ +package migrator diff --git a/pkg/database/postgres/prepared_statement.go b/pkg/database/postgres/prepared_statement.go new file mode 100644 index 00000000..cebc2703 --- /dev/null +++ b/pkg/database/postgres/prepared_statement.go @@ -0,0 +1,9 @@ +package postgres + +type statementKey uint + +const ( + StatementKeyAWalletGetTransactionHistory statementKey = iota + 1 + StatementKeyWalletInsertTransaction + StatementKeyWalletGetUserWallet +) diff --git a/pkg/err_msg/message.go b/pkg/err_msg/message.go index 67924a94..c6c4fb81 100644 --- a/pkg/err_msg/message.go +++ b/pkg/err_msg/message.go @@ -1,6 +1,23 @@ package errmsg +type ErrorResponse struct { + Message string `json:"message"` // General error message + Errors map[string]interface{} `json:"errors,omitempty"` // Additional detail of error + InternalErrCode string `json:"internal_error_code,omitempty"` // Custom error code (optional) +} + +func (e ErrorResponse) Error() string { + return e.Message +} + const ( + ErrValidationFailed = "input validation failed" + ErrUnexpectedError = "unexpected error occurred" + ErrInvalidRequestFormat = "invalid request format" + ErrGetUserInfo = "get user info failed" + ErrFailedDecodeBase64 = "decode data to base 64 failed" + ErrFailedUnmarshalJson = "unmarshal data to JSON failed" + ErrUnauthorized = "unauthorized" ErrorMsgAdminNotAllowed = "admin is not allowed" ErrorMsgNotFound = "record not found" ErrorMsgSomethingWentWrong = "something went wrong" diff --git a/productapp/app.go b/productapp/app.go index fc658cbf..85d86403 100644 --- a/productapp/app.go +++ b/productapp/app.go @@ -1,8 +1,69 @@ package productapp -import "net/http" +import ( + "context" + "fmt" + "git.gocasts.ir/ebhomengo/niki/productapp/service/product" + "log" + + httpserver "git.gocasts.ir/ebhomengo/niki/delivery/http_server" + producthttp "git.gocasts.ir/ebhomengo/niki/productapp/delivery/http" + productdb "git.gocasts.ir/ebhomengo/niki/productapp/repository/database" + "git.gocasts.ir/ebhomengo/niki/repository/mysql" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +type HTTPServerConfig struct { + Port int +} + +type Config struct { + HTTPServer HTTPServerConfig + Database mysql.Config +} type Application struct { Config Config - HTTPServer *http.Server + HTTPServer *producthttp.Server + productSvc product.Service + DB *productdb.DB + Echo *echo.Echo +} + +func Setup(cfg Config) *Application { + db := mysql.New(cfg.Database) + productDB := productdb.New(db) + + e := echo.New() + e.Use(middleware.Recover()) + + hsrv := &httpserver.Server{ + Router: e, + } + + productSvc := product.New(productDB) + + srv := producthttp.NewServer(hsrv, productSvc) + srv.RegisterRoutes() + + return &Application{ + Config: cfg, + productSvc: productSvc, + HTTPServer: srv, + DB: productDB, + Echo: e, + } +} + +func (app *Application) Start() { + address := fmt.Sprintf(":%d", app.Config.HTTPServer.Port) + log.Printf("Product Service listening on %s", address) + if err := app.Echo.Start(address); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} + +func (app *Application) Stop(ctx context.Context) error { + return app.Echo.Shutdown(ctx) } diff --git a/productapp/config.go b/productapp/config.go index 64c7837d..3b63d7be 100644 --- a/productapp/config.go +++ b/productapp/config.go @@ -1,4 +1 @@ package productapp - -type Config struct { -} diff --git a/productapp/delivery/http/handler.go b/productapp/delivery/http/handler.go index e9549c3c..871c7cab 100644 --- a/productapp/delivery/http/handler.go +++ b/productapp/delivery/http/handler.go @@ -1,17 +1,38 @@ package http import ( - "net/http" - + errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg" + "git.gocasts.ir/ebhomengo/niki/productapp/service/product" "github.com/labstack/echo/v4" + "log/slog" + "net/http" ) -type Handler struct{} - -func NewHandler() *Handler { - return &Handler{} +type Handler struct { + productService product.Service + Logger *slog.Logger } -func (h Handler) HealthCheck(c echo.Context) error { - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) +func NewHandler(productService product.Service) *Handler { + return &Handler{ + productService: productService, + } +} + +func (h Handler) getProductList(c echo.Context) error { + var req product.GetProductListRequest + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, errmsg.ErrorResponse{ + Message: errmsg.ErrInvalidRequestFormat, + }) + } + + response, err := h.productService.GetProducts(c.Request().Context(), req) + if err != nil { + // todo handle validation error + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, response) } diff --git a/productapp/delivery/http/health_check.go b/productapp/delivery/http/health_check.go new file mode 100644 index 00000000..9f08f770 --- /dev/null +++ b/productapp/delivery/http/health_check.go @@ -0,0 +1,11 @@ +package http + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +func (h Handler) HealthCheck(c echo.Context) error { + return c.JSON(http.StatusOK, map[string]string{"status": "healthy"}) +} diff --git a/productapp/delivery/http/route.go b/productapp/delivery/http/route.go deleted file mode 100644 index d02cfda6..00000000 --- a/productapp/delivery/http/route.go +++ /dev/null @@ -1 +0,0 @@ -package http diff --git a/productapp/delivery/http/server.go b/productapp/delivery/http/server.go index c5adfb44..bb9069f6 100644 --- a/productapp/delivery/http/server.go +++ b/productapp/delivery/http/server.go @@ -1,16 +1,19 @@ package http -import httpserver "git.gocasts.ir/ebhomengo/niki/delivery/http_server" +import ( + httpserver "git.gocasts.ir/ebhomengo/niki/delivery/http_server" + "git.gocasts.ir/ebhomengo/niki/productapp/service/product" +) type Server struct { HTTPServer *httpserver.Server Handler *Handler } -func NewServer(httpserver *httpserver.Server) *Server { +func NewServer(httpserver *httpserver.Server, productSvc product.Service) *Server { return &Server{ HTTPServer: httpserver, - Handler: NewHandler(), + Handler: NewHandler(productSvc), } } @@ -20,4 +23,12 @@ func (s *Server) Serve() { func (s *Server) Stop() {} -func (s *Server) RegisterRoutes() {} +func (s *Server) RegisterRoutes() { + r := s.HTTPServer.Router + r.GET("health-check", s.Handler.HealthCheck) + + productGroup := r.Group("/v1/products") + + productGroup.GET("", s.Handler.getProductList) + +} diff --git a/productapp/repository/database/db.go b/productapp/repository/database/db.go index 5d07c611..d969dc5e 100644 --- a/productapp/repository/database/db.go +++ b/productapp/repository/database/db.go @@ -1,6 +1,11 @@ package database -import "git.gocasts.ir/ebhomengo/niki/repository/mysql" +import ( + "context" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + "git.gocasts.ir/ebhomengo/niki/productapp/service/product" + "git.gocasts.ir/ebhomengo/niki/repository/mysql" +) type DB struct { conn *mysql.DB @@ -11,3 +16,42 @@ func New(conn *mysql.DB) *DB { conn: conn, } } + +func (db *DB) GetProducts(ctx context.Context, page, pageSize uint) ([]product.Product, error) { + const Op = "domain.repository.mysql.order.get-shipping" + + query := "SELECT * FROM products ORDER BY id DESC limit ? offset ?" + rows, err := db.conn.Conn().Query(query, page, pageSize) + + if rows.Err() != nil { + return []product.Product{}, richerror.New(Op).WithErr(err) + } + + defer rows.Close() + + var products []product.Product + + for rows.Next() { + var s product.Product + + err := rows.Scan( + &s.ID, + &s.Name, + &s.Slug, + &s.Description, + &s.Price, + &s.Stock, + &s.IsActive, + &s.Features, + &s.CreatedAt, + &s.DeletedAt, + ) + if err != nil { + return nil, richerror.New(Op).WithErr(err) + } + + products = append(products, s) + } + + return products, nil +} diff --git a/productapp/repository/migrations/1775595705_create_products_table.sql b/productapp/repository/migrations/1775595705_create_products_table.sql index 23e210c1..5a061b8d 100644 --- a/productapp/repository/migrations/1775595705_create_products_table.sql +++ b/productapp/repository/migrations/1775595705_create_products_table.sql @@ -9,8 +9,7 @@ CREATE TABLE `products` ( `is_active` BOOLEAN DEFAULT TRUE, `features` JSON DEFAULT NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - `deleted_at` TIMESTAMP DEFAULT NULL, - FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) ON DELETE SET NULL + `deleted_at` TIMESTAMP DEFAULT NULL ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_persian_ci; -- +migrate Down diff --git a/productapp/service/product/entity.go b/productapp/service/product/entity.go index b3dc5901..72f7c04e 100644 --- a/productapp/service/product/entity.go +++ b/productapp/service/product/entity.go @@ -6,16 +6,16 @@ import ( ) type Product struct { - ID uint - Name string - Slug string - Description string - Price float64 - Stock int - IsActive bool - Features string - CreatedAt time.Time - DeletedAt sql.NullTime + ID uint `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Price float64 `json:"price"` + Stock int `json:"stock"` + IsActive bool `json:"is_active"` + Features string `json:"features"` + CreatedAt time.Time `json:"created_at"` + DeletedAt sql.NullTime `json:"-"` } type ProductImage struct { diff --git a/productapp/service/product/message.go b/productapp/service/product/message.go new file mode 100644 index 00000000..6e6d3b59 --- /dev/null +++ b/productapp/service/product/message.go @@ -0,0 +1,13 @@ +package product + +import "errors" + +var ( + ErrFailedGetProductList = errors.New("failed to get products list") + ErrFailedCreateProduct = errors.New("failed to create product") + ErrFailedFindProduct = errors.New("failed to find product") + ErrCantAccessToUpdateProduct = errors.New("you are not allowed to edit this product") + ErrFailedUpdateProduct = errors.New("failed to update product") + ErrFailedToPreparedStatement = errors.New("failed to prepared statement: %v") + ErrCantAccess = errors.New("you are not allowed to this action") +) diff --git a/productapp/service/product/param.go b/productapp/service/product/param.go index e6ae0918..1774ea9e 100644 --- a/productapp/service/product/param.go +++ b/productapp/service/product/param.go @@ -1 +1,12 @@ package product + +import "git.gocasts.ir/ebhomengo/niki/param" + +type GetProductListRequest struct { + param.PaginationRequest +} + +type GetProductListResponse struct { + Data []Product `json:"data"` + param.PaginationResponse +} diff --git a/productapp/service/product/service.go b/productapp/service/product/service.go index e6ae0918..a372f2e6 100644 --- a/productapp/service/product/service.go +++ b/productapp/service/product/service.go @@ -1 +1,38 @@ package product + +import ( + "context" + "fmt" + errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg" +) + +type Repository interface { + GetProducts(ctx context.Context, page, pageSize uint) ([]Product, error) +} + +type Service struct { + repository Repository +} + +func New(repo Repository) Service { + return Service{ + repository: repo, + } +} + +func (s Service) GetProducts(ctx context.Context, req GetProductListRequest) (GetProductListResponse, error) { + + page := req.GetPageNumber() + pageSize := req.PageSize + + products, err := s.repository.GetProducts(ctx, page, pageSize) + if err != nil { + fmt.Println(err) + return GetProductListResponse{}, errmsg.ErrorResponse{ + Message: ErrFailedGetProductList.Error(), + Errors: map[string]interface{}{"repository_error": err.Error()}, + } + } + + return GetProductListResponse{Data: products}, nil +} diff --git a/walletapp/app.go b/walletapp/app.go new file mode 100644 index 00000000..c2f98f84 --- /dev/null +++ b/walletapp/app.go @@ -0,0 +1,12 @@ +package walletapp + +type Application struct { +} + +func SetUp() { + +} + +func (app Application) StartServer() {} + +func (app Application) StopServer() {} diff --git a/walletapp/config.go b/walletapp/config.go new file mode 100644 index 00000000..a011ca94 --- /dev/null +++ b/walletapp/config.go @@ -0,0 +1,15 @@ +package walletapp + +import ( + "git.gocasts.ir/ebhomengo/niki/adapter/redis" + "git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/repository" + "git.gocasts.ir/ebhomengo/niki/pkg/httpserver" + logger "git.gocasts.ir/ebhomengo/niki/pkg/logger" +) + +type Config struct { + Redis redis.Config `koanf:"redis" json:"redis"` + Repo repository.Config `koanf:"repo" json:"repo"` + HTTPServer httpserver.Config `koanf:"http_server" json:"http_server"` + Logger logger.Config `koanf:"logger" json:"logger"` +} diff --git a/walletapp/delivery/httpserver/health_check.go b/walletapp/delivery/httpserver/health_check.go new file mode 100644 index 00000000..b482627c --- /dev/null +++ b/walletapp/delivery/httpserver/health_check.go @@ -0,0 +1 @@ +package httpserver diff --git a/walletapp/delivery/httpserver/server.go b/walletapp/delivery/httpserver/server.go new file mode 100644 index 00000000..b482627c --- /dev/null +++ b/walletapp/delivery/httpserver/server.go @@ -0,0 +1 @@ +package httpserver diff --git a/walletapp/delivery/httpserver/transaction/handler.go b/walletapp/delivery/httpserver/transaction/handler.go new file mode 100644 index 00000000..06192075 --- /dev/null +++ b/walletapp/delivery/httpserver/transaction/handler.go @@ -0,0 +1 @@ +package transaction