Compare commits

...

10 Commits

23 changed files with 1094 additions and 357 deletions

View File

@ -3,9 +3,9 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
purchaseMysql "git.gocasts.ir/ebhomengo/niki/domain/purchase/repository/mysql" purchaseMysql "git.gocasts.ir/ebhomengo/niki/domain/order/repository/mysql"
order "git.gocasts.ir/ebhomengo/niki/domain/order/service"
"git.gocasts.ir/ebhomengo/niki/purchaseapp/delivery/http" "git.gocasts.ir/ebhomengo/niki/purchaseapp/delivery/http"
"git.gocasts.ir/ebhomengo/niki/purchaseapp/service/order"
"git.gocasts.ir/ebhomengo/niki/repository/migrator" "git.gocasts.ir/ebhomengo/niki/repository/migrator"
"git.gocasts.ir/ebhomengo/niki/repository/mysql" "git.gocasts.ir/ebhomengo/niki/repository/mysql"
) )

View File

@ -1 +1,52 @@
package command package command
import (
"git.gocasts.ir/ebhomengo/niki/pkg/database/migrator"
"git.gocasts.ir/ebhomengo/niki/pkg/logger"
"github.com/spf13/cobra"
)
var up bool
var down bool
var migrateCmd = &cobra.Command{
Use: "migrate",
Short: "Run database migrations",
Long: `This command runs the database migrations for the shoppingbasket service.`,
Run: func(cmd *cobra.Command, args []string) {
migrate()
},
}
func migrate() {
var cfg = loadAppConfig()
logger.Init(cfg.Logger)
l := logger.L()
migrationCfg := migrator.Config{
MysqlConfig: cfg.Mysql,
MigrationPath: cfg.PathOfMigration,
MigrationDBName: "gorp_migrations",
}
if migrateUp || migrateDown {
mgr := migrator.New(migrationCfg)
if migrateUp {
l.Info("Running migrations up...")
mgr.Up()
l.Info("Migrations up completed.")
}
if migrateDown {
l.Info("Running migrations down...")
mgr.Down()
l.Info("Migrations down completed.")
}
}
}
func init() {
migrateCmd.Flags().BoolVar(&up, "up", false, "Run migrations up")
migrateCmd.Flags().BoolVar(&down, "down", false, "Run migrations down")
RootCmd.AddCommand(migrateCmd)
}

View File

@ -32,7 +32,7 @@ func loadAppConfig() shoppingbasketapp.Config {
if _, err := os.Stat(defaultConfig); err == nil { if _, err := os.Stat(defaultConfig); err == nil {
yamlPath = defaultConfig yamlPath = defaultConfig
} else { } else {
yamlPath = filepath.Join(projectRoot, "deploy", "shoppingbasket", "development", "config.local.yml") yamlPath = filepath.Join(projectRoot, "deploy", "shoppingbasket", "development", "config.yml")
} }
} }
@ -45,7 +45,7 @@ func loadAppConfig() shoppingbasketapp.Config {
} }
if err := cfgloader.Load(options, &cfg); err != nil { if err := cfgloader.Load(options, &cfg); err != nil {
log.Fatalf("Failed to load benefactor config: %v", err) log.Fatalf("Failed to load shoppingbasket config: %v", err)
} }
return cfg return cfg

View File

@ -5,27 +5,32 @@ import (
"fmt" "fmt"
"git.gocasts.ir/ebhomengo/niki/pkg/logger" "git.gocasts.ir/ebhomengo/niki/pkg/logger"
"git.gocasts.ir/ebhomengo/niki/shoppingbasketapp" "git.gocasts.ir/ebhomengo/niki/shoppingbasketapp"
"github.com/labstack/gommon/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"log"
) )
var migrateUp bool
var migrateDown bool
var ServeCmd = &cobra.Command{ var ServeCmd = &cobra.Command{
Use: "serve", Use: "serve",
Short: "Start shoppingbasket service", Short: "Start shoppingbasket service",
Long: `This command starts the main shoppingbasket service.`, Long: `This command starts the main shoppingbasket service.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
serve()
}, },
} }
func serve() { func serve() {
var cfg = loadAppConfig() var cfg shoppingbasketapp.Config = loadAppConfig()
logger.Init(cfg.Logger) logger.Init(cfg.Logger)
l := logger.L() l := logger.L()
l.Info("Starting shoppingbasket service...") l.Info("Starting shoppingbasket service...")
migrate()
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -39,5 +44,8 @@ func serve() {
} }
func init() { func init() {
ServeCmd.Flags().BoolVar(&migrateUp, "migrate-up", false, "Run migrations up before starting the server")
ServeCmd.Flags().BoolVar(&migrateDown, "migrate-down", false, "Run migrations down before starting the server")
ServeCmd.MarkFlagsMutuallyExclusive("migrate-up", "migrate-down")
RootCmd.AddCommand(ServeCmd) RootCmd.AddCommand(ServeCmd)
} }

View File

@ -4,9 +4,17 @@ redis:
password: "" password: ""
db: 0 db: 0
mysql:
port: 3306
host: localhost
db_name: niki_db
username: niki
password: nikiappt0lk2o20
repo: repo:
kart_key_prefix: "shopping-basket-cart:" cache_kart_key_prefix: "shopping-basket-cart:user_id"
ttl: 3600s cache_ttl: 2h # 24h
mysql_ttl: 168h # 7d
http_server: http_server:
host: "localhost" host: "localhost"
@ -16,10 +24,12 @@ http_server:
allow_origins: allow_origins:
- "*" - "*"
path_of_migration: "./domain/shoppingbasket/repository/migration"
logger: logger:
level: "debug" # Can be `debug`, `info`, `warn`, `error` level: "debug" # Can be `debug`, `info`, `warn`, `error`
file_path: "logs/shoppingbasketapp/service.log" file_path: "./logs/service.log"
use_local_time: true use_local_time: true
file_max_size_in_mb: 10 file_max_size_in_mb: 10
file_max_age_in_days: 7 file_max_age_in_days: 7

View File

@ -2,20 +2,46 @@ package entity
import ( import (
"git.gocasts.ir/ebhomengo/niki/types" "git.gocasts.ir/ebhomengo/niki/types"
"time"
) )
type CartStatus string
type Item struct { type Item struct {
ID types.ID
CartID types.ID
ProductID types.ID ProductID types.ID
UserID types.ID
Quantity int Quantity int
Price types.Price Price float64
Name string Name string
AddedAt int64 AddedAt time.Time
UpdatedAt time.Time
} }
type Cart struct { type Cart struct {
ID types.ID
UserID types.ID UserID types.ID
Items []Item Items []Item
TotalPrice types.Price Status CartStatus
ExpireAt int64 TotalPrice float64
CreatedAt int64 ExpireAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
const (
CartStatusActive CartStatus = "active"
CartStatusExpired CartStatus = "expired"
CartStatusCheckedOut CartStatus = "checked_out"
)
func (c CartStatus) IsValid() bool {
switch c {
case CartStatusExpired, CartStatusActive, CartStatusCheckedOut:
return true
default:
return false
}
} }

View File

@ -0,0 +1,123 @@
package repository
import (
"context"
"fmt"
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
"git.gocasts.ir/ebhomengo/niki/types"
"time"
)
type Config struct {
CacheKartKeyPrefix string `koanf:"cache_kart_key_prefix"`
CacheTTL time.Duration `koanf:"cache_ttl"`
MysqlTTL time.Duration `koanf:"mysql_ttl"`
}
type Repo struct {
db DB
cache Cache
}
func New(db DB, cache Cache) Repo {
return Repo{db: db, cache: cache}
}
func op(s string) string {
return fmt.Sprintf("shoppingbasketapp-repository-", s)
}
func (r Repo) AddItem(ctx context.Context, userID types.ID, item entity.Item) error {
if err := r.db.addToBasket(ctx, userID, item); err != nil {
return err
}
cart, err := r.db.getCart(ctx, nil, FindCartByUserIDQuery, userID)
if err != nil {
return err
}
return r.cache.upsertCart(ctx, cart)
}
func (r Repo) GetCart(ctx context.Context, userID types.ID) (entity.Cart, error) {
cart, err := r.cache.getCart(ctx, userID)
if err != nil {
return entity.Cart{}, err
}
if cart.ID < 1 || len(cart.Items) < 1 {
c, err := r.db.getCart(ctx, nil, FindCartByUserIDQuery, userID)
if err != nil {
return entity.Cart{}, err
}
if c.ID < 1 {
return entity.Cart{}, nil
}
if err := r.cache.upsertCart(ctx, c); err != nil {
return entity.Cart{}, err
}
return c, nil
}
return cart, nil
}
func (r Repo) UpdateQuantity(ctx context.Context, cartID, itemID types.ID, quantity int) error {
cart, err := r.db.getCart(ctx, nil, FindCartByIDQuery, cartID)
if err != nil {
return err
}
for i, item := range cart.Items {
if item.ID == itemID {
cart.Items[i].Quantity = quantity
item.Quantity = quantity
if err := r.db.updateQuantity(ctx, nil, item); err != nil {
return err
}
}
}
return r.cache.upsertCart(ctx, cart)
}
func (r Repo) UpdateStatus(ctx context.Context, cartID types.ID, status entity.CartStatus) error {
if err := r.db.updateCartStatus(ctx, nil, cartID, status); err != nil {
return err
}
cart, err := r.db.getCart(ctx, nil, FindCartByIDQuery, cartID)
if err != nil {
return err
}
return r.cache.upsertCart(ctx, cart)
}
func (r Repo) DeleteItem(ctx context.Context, cartID, itemID types.ID) error {
if err := r.db.deleteItem(ctx, nil, cartID, itemID); err != nil {
return err
}
c, err := r.db.getCart(ctx, nil, FindCartByIDQuery, cartID)
if err != nil {
return err
}
return r.cache.upsertCart(ctx, c)
}
func (r Repo) DeleteCart(ctx context.Context, cartID, userID types.ID) error {
if err := r.db.deleteCart(ctx, cartID, userID); err != nil {
return err
}
return r.cache.deleteCart(ctx, userID)
}

View File

@ -0,0 +1,226 @@
package repository
import (
"context"
"encoding/json"
"fmt"
adapter "git.gocasts.ir/ebhomengo/niki/adapter/redis"
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
"git.gocasts.ir/ebhomengo/niki/types"
"github.com/redis/go-redis/v9"
"strconv"
"time"
)
type Cache struct {
client *adapter.Adapter
config Config
}
func NewCache(client *adapter.Adapter, cfg Config) Cache {
return Cache{client: client, config: cfg}
}
func (c Cache) cartKey(userID types.ID) string {
return fmt.Sprintf("%s:%d", c.config.CacheKartKeyPrefix, userID)
}
func (c Cache) itemKey(itemID types.ID) string {
return fmt.Sprintf("item:%d", itemID)
}
func (c Cache) kartIDsKey(userID types.ID) string {
return fmt.Sprintf("cart:%d:items", userID)
}
func (c Cache) upsertCart(ctx context.Context, cart entity.Cart) error {
cartKey := c.cartKey(cart.UserID)
cartIDsKey := c.kartIDsKey(cart.UserID)
pipe := c.client.Client().Pipeline()
pipe.HSet(ctx, cartKey, map[string]interface{}{
IDField: cart.ID,
UserIDField: cart.UserID,
StatusField: cart.Status,
TotalPriceField: cart.TotalPrice,
ExpireAtField: cart.ExpireAt,
CreatedAtField: cart.CreatedAt,
UpdatedAtField: cart.UpdatedAt,
})
pipe.Expire(ctx, cartKey, c.config.CacheTTL)
pipe.Del(ctx, cartIDsKey)
for _, i := range cart.Items {
pipe.SAdd(ctx, cartIDsKey, i.ID)
itemKey := c.itemKey(i.ID)
jsonItem, err := json.Marshal(i)
if err != nil {
return richerror.New(richerror.Op(op("cache.upsertCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
pipe.Set(ctx, itemKey, string(jsonItem), c.config.CacheTTL)
}
pipe.Expire(ctx, cartIDsKey, c.config.CacheTTL)
_, err := pipe.Exec(ctx)
if err != nil {
return richerror.New(richerror.Op(op("cache.upsertCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
return nil
}
func (c Cache) getCart(ctx context.Context, userID types.ID) (entity.Cart, error) {
cartKey := c.cartKey(userID)
cartIDsKey := c.kartIDsKey(userID)
pipe := c.client.Client().Pipeline()
resCart := pipe.HGetAll(ctx, cartKey)
resIDs := pipe.SMembers(ctx, cartIDsKey)
_, err := pipe.Exec(ctx)
if err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
result := resCart.Val()
if len(result) < 1 {
return entity.Cart{}, nil
}
cart, err := parseCartFromRedis(result)
if err != nil {
return entity.Cart{}, err
}
if cart.ID < 1 {
return entity.Cart{}, nil
}
ids := resIDs.Val()
if len(ids) < 1 {
return cart, nil
}
itemPipe := c.client.Client().Pipeline()
itemResults := make([]*redis.StringCmd, len(ids))
for i, idStr := range ids {
id, _ := strconv.Atoi(idStr)
itemKey := c.itemKey(types.ID(id))
cmd := itemPipe.Get(ctx, itemKey)
itemResults[i] = cmd
}
if _, err := itemPipe.Exec(ctx); err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
for _, cmd := range itemResults {
if err := cmd.Err(); err != nil {
if err == redis.Nil {
continue
}
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
var item entity.Item
if err := json.Unmarshal([]byte(cmd.Val()), &item); err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
cart.Items = append(cart.Items, item)
}
return cart, nil
}
func parseCartFromRedis(result map[string]string) (entity.Cart, error) {
if len(result) == 0 {
return entity.Cart{}, nil
}
var cart entity.Cart
cart.Items = []entity.Item{}
for key, val := range result {
switch key {
case IDField:
id, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
cart.ID = types.ID(id)
case UserIDField:
uid, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
cart.UserID = types.ID(uid)
case StatusField:
cart.Status = entity.CartStatus(val)
case TotalPriceField:
price, err := strconv.ParseFloat(val, 64)
if err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
cart.TotalPrice = price
case ExpireAtField, CreatedAtField, UpdatedAtField:
t, err := time.Parse(time.RFC3339, val)
if err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
switch key {
case ExpireAtField:
cart.ExpireAt = t
case CreatedAtField:
cart.CreatedAt = t
case UpdatedAtField:
cart.UpdatedAt = t
}
}
}
return cart, nil
}
func (c Cache) deleteCart(ctx context.Context, userID types.ID) error {
cartKey := c.cartKey(userID)
cartIDsKey := c.kartIDsKey(userID)
pipe := c.client.Client().Pipeline()
pipe.Del(ctx, cartKey)
pipe.Del(ctx, cartIDsKey)
_, err := pipe.Exec(ctx)
if err != nil {
return richerror.New(richerror.Op(op("cache-deleteCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
return nil
}

View File

@ -1,272 +0,0 @@
package repository
import (
"context"
"encoding/json"
"fmt"
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
"git.gocasts.ir/ebhomengo/niki/types"
"github.com/redis/go-redis/v9"
"strconv"
"strings"
"time"
)
const (
FieldNumber = 5
UserIDField = "user_id"
CreatedAtField = "created_at"
ExpireAtField = "expire_at"
TotalPriceField = "total_price"
)
type Config struct {
KartKeyPrefix string `koanf:"kart_key_prefix"`
TTL time.Duration `koanf:"ttl"`
}
type Repo struct {
client *redis.Client
config Config
}
func New(client *redis.Client, cfg Config) Repo {
return Repo{client: client, config: cfg}
}
func (r Repo) cartKey(userID types.ID) string {
return r.config.KartKeyPrefix + fmt.Sprintf("%d", userID)
}
func (r Repo) itemKey(productID types.ID) string {
return fmt.Sprintf("item:%d", productID)
}
func (r Repo) AddItem(ctx context.Context, userID types.ID, item entity.Item) error {
const op = "shoppingbasketapp.repository.AddItem"
cartKey := r.cartKey(userID)
itemKey := r.itemKey(item.ProductID)
now := time.Now().UnixNano()
itemJson, _ := json.Marshal(item)
exists, _ := r.client.Exists(ctx, cartKey).Result()
if exists == 0 {
r.client.HSet(ctx, cartKey, map[string]interface{}{
UserIDField: userID,
itemKey: string(itemJson),
TotalPriceField: item.Price * types.Price(item.Quantity),
CreatedAtField: now,
ExpireAtField: now + r.config.TTL.Nanoseconds(),
})
} else {
existsItem, _ := r.client.HGet(ctx, cartKey, itemKey).Result()
if existsItem != "" {
var i entity.Item
if err := json.Unmarshal([]byte(existsItem), &i); err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
item.Quantity += i.Quantity
itemJson, _ = json.Marshal(item)
}
r.client.HSet(ctx, cartKey, itemKey, string(itemJson))
r.client.HSet(ctx, cartKey, ExpireAtField, now+r.config.TTL.Nanoseconds())
if err := r.updateTotalPrice(ctx, cartKey); err != nil {
return err
}
}
r.client.Expire(ctx, cartKey, r.config.TTL)
return nil
}
func parsInt(s string) int64 {
i, _ := strconv.ParseInt(s, 10, 64)
return i
}
func (r Repo) GetCart(ctx context.Context, userID types.ID) (entity.Cart, error) {
const op = "shoppingbasketapp.repository.GetCart"
cartKey := r.cartKey(userID)
exists, err := r.client.Exists(ctx, cartKey).Result()
if err != nil {
return entity.Cart{}, richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
if exists == 0 {
return entity.Cart{}, richerror.New(op).WithKind(richerror.KindNotFound).WithMessage("not found shopping basket")
}
allCart, err := r.client.HGetAll(ctx, cartKey).Result()
if err != nil {
return entity.Cart{}, richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
c := entity.Cart{Items: []entity.Item{}}
for field, value := range allCart {
if strings.HasPrefix(field, "item:") {
var i entity.Item
if err := json.Unmarshal([]byte(value), &i); err != nil {
return entity.Cart{}, richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
c.Items = append(c.Items, i)
continue
}
switch field {
case UserIDField:
c.UserID = types.ID(parsInt(value))
case TotalPriceField:
c.TotalPrice = types.Price(parsInt(value))
case CreatedAtField:
c.CreatedAt = parsInt(value)
case ExpireAtField:
c.ExpireAt = parsInt(value)
}
}
return c, nil
}
func (r Repo) DeleteItem(ctx context.Context, userID, productID types.ID) error {
const op = "shoppingbasketapp.repository.DeleteItem"
cartKey := r.cartKey(userID)
itemKey := r.itemKey(productID)
if err := r.existsCart(ctx, cartKey); err != nil {
return err
}
if err := r.existsItem(ctx, cartKey, itemKey); err != nil {
return err
}
if err := r.client.HDel(ctx, cartKey, itemKey).Err(); err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
num, err := r.client.HLen(ctx, cartKey).Result()
if err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
if num < FieldNumber {
return r.DeleteCart(ctx, userID)
}
return r.updateTotalPrice(ctx, cartKey)
}
func (r Repo) UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error {
const op = "shoppingbasketapp.repository.UpdateQuantity"
cartKey := r.cartKey(userID)
itemKey := r.itemKey(productID)
if err := r.existsCart(ctx, cartKey); err != nil {
return err
}
if err := r.existsItem(ctx, cartKey, itemKey); err != nil {
return err
}
data, err := r.client.HGet(ctx, cartKey, itemKey).Result()
if err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
var item entity.Item
if err := json.Unmarshal([]byte(data), &item); err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
item.Quantity = quantity
j, _ := json.Marshal(item)
if err := r.client.HSet(ctx, cartKey, itemKey, string(j)).Err(); err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
return r.updateTotalPrice(ctx, cartKey)
}
func (r Repo) DeleteCart(ctx context.Context, userID types.ID) error {
const op = "shoppingbasketapp.repository.DeleteCart"
cartKey := r.cartKey(userID)
if err := r.existsCart(ctx, cartKey); err != nil {
return err
}
if err := r.client.Del(ctx, cartKey).Err(); err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
return nil
}
func (r Repo) updateTotalPrice(ctx context.Context, cartKey string) error {
const op = "shoppingbasketapp.repository.updateTotalPrice"
allFields, err := r.client.HGetAll(ctx, cartKey).Result()
if err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
var total types.Price
for field, value := range allFields {
if strings.HasPrefix(field, "item:") {
var item entity.Item
if err := json.Unmarshal([]byte(value), &item); err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
total += item.Price * types.Price(item.Quantity)
}
}
return r.client.HSet(ctx, cartKey, TotalPriceField, int64(total)).Err()
}
func (r Repo) existsCart(ctx context.Context, cartKey string) error {
const op = "shoppingbasketapp.repository.existsCart"
exists, err := r.client.Exists(ctx, cartKey).Result()
if err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
if exists == 0 {
return richerror.New(op).WithKind(richerror.KindNotFound).WithMessage("not found shopping basket")
}
return nil
}
func (r Repo) existsItem(ctx context.Context, cartKey, itemKey string) error {
const op = "shoppingbasketapp.repository.existsCart"
exists, err := r.client.HExists(ctx, cartKey, itemKey).Result()
if err != nil {
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
}
if !exists {
return richerror.New(op).WithKind(richerror.KindNotFound).WithMessage("not found product form shopping basket")
}
return nil
}

View File

@ -0,0 +1,29 @@
package repository
import "errors"
const (
FindCartByUserIDQuery = `SELECT id, user_id, total_price, status, expire_at, created_at
FROM carts
WHERE user_id = ? AND status = 'active' AND deleted_at IS NULL
ORDER BY created_at DESC`
FindCartByIDQuery = `SELECT id, user_id, total_price, status, expire_at, created_at
FROM carts
WHERE id = ? AND status = 'active' AND deleted_at IS NULL`
)
var (
ErrNotExistsCart = errors.New("not exists cart")
ErrExpiredCart = errors.New("expired cart")
)
const (
IDField = "id"
UserIDField = "user_id"
StatusField = "status"
TotalPriceField = "total_price"
CreatedAtField = "created_at"
ExpireAtField = "expire_at"
UpdatedAtField = "updated_at"
)

View File

@ -0,0 +1,19 @@
-- +migrate Up
CREATE TABLE `carts` (
`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL,
`total_price` DECIMAL(10,2) DEFAULT 0,
`status` ENUM('active', 'expired', 'checked_out') NOT NULL DEFAULT 'active',
`expire_at` TIMESTAMP NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`deleted_at` TIMESTAMP NULL DEFAULT NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE INDEX `idx_carts_user_id` ON `carts`(`user_id`);
CREATE INDEX `idx_carts_id` ON `carts`(`id`);
-- +migrate Down
DROP INDEX `idx_carts_user_id` ON `carts`;
DROP INDEX `idx_carts_id` ON `carts`;
DROP TABLE `carts`;

View File

@ -0,0 +1,20 @@
-- +migrate Up
CREATE TABLE `items` (
`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`cart_id` BIGINT UNSIGNED NOT NULL,
`product_id` BIGINT UNSIGNED NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL,
`quantity` INT NOT NULL DEFAULT 1,
`price` DECIMAL(10,2) NOT NULL,
`name` VARCHAR(256) NOT NULL ,
`added_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`deleted_at` TIMESTAMP NULL DEFAULT NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`cart_id`) REFERENCES `carts`(`id`)
);
CREATE INDEX `idx_items_cart_id` ON `items`(`cart_id`);
-- +migrate Down
DROP INDEX `idx_items_cart_id` ON `items`;
DROP TABLE `items`;

View File

@ -0,0 +1,423 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
"git.gocasts.ir/ebhomengo/niki/pkg/database/mysql"
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
"git.gocasts.ir/ebhomengo/niki/types"
"time"
)
type DB struct {
db *mysql.DB
config Config
}
func NewDB(db *mysql.DB, cfg Config) DB {
return DB{db: db, config: cfg}
}
func (d DB) getCart(ctx context.Context, tx *sql.Tx, query string, by types.ID) (entity.Cart, error) {
t, ok, err := d.getLocalTX(ctx, tx, "mysql-getCart")
if err != nil {
return entity.Cart{}, err
}
if ok {
defer func() {
_ = tx.Rollback()
}()
}
var c entity.Cart
err = t.QueryRowContext(ctx, query, by).
Scan(&c.ID, &c.UserID, &c.TotalPrice, &c.Status, &c.ExpireAt, &c.CreatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
WithKind(richerror.KindNotFound).
WithErr(ErrNotExistsCart)
}
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
WithKind(richerror.KindUnexpected).
WithErr(err)
}
if c.ExpireAt.Before(time.Now().UTC()) {
if err := d.updateCartStatus(ctx, t, c.ID, entity.CartStatusExpired); err != nil {
return entity.Cart{}, err
}
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
WithKind(richerror.KindInvalid).WithErr(ErrExpiredCart)
}
itemsQuery := `SELECT id, cart_id, user_id, product_id, quantity, price, name, added_at, updated_at
FROM items
WHERE cart_id=? AND deleted_at IS NULL
ORDER BY added_at`
rows, err := t.QueryContext(ctx, itemsQuery, c.ID)
if err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
WithKind(richerror.KindUnexpected).
WithErr(err)
}
defer rows.Close()
items := make([]entity.Item, 0)
for rows.Next() {
var i entity.Item
if err := rows.Scan(&i.ID, &i.CartID, &i.UserID, &i.ProductID,
&i.Quantity, &i.Price, &i.Name, &i.AddedAt, &i.UpdatedAt); err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
WithKind(richerror.KindUnexpected).
WithErr(fmt.Errorf("error scan rows: %w", err))
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
WithKind(richerror.KindUnexpected).
WithErr(err)
}
c.Items = items
if ok {
if err := tx.Commit(); err != nil {
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
WithKind(richerror.
KindUnexpected).WithErr(err)
}
}
return c, nil
}
func (d DB) addToBasket(ctx context.Context, userID types.ID, item entity.Item) error {
tx, err := d.db.Conn().BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
})
if err != nil {
return richerror.New(richerror.Op(op("mysql-addToBasket"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
defer func() {
_ = tx.Rollback()
}()
c, err := d.getCart(ctx, tx, FindCartByUserIDQuery, userID)
if err != nil {
if err.Error() == ErrExpiredCart.Error() || err.Error() == ErrNotExistsCart.Error() {
cartQuery := `INSERT INTO carts user_id, total_price, status, expire_at, created_at
VALUES (?,?,?,?)`
newCart := entity.Cart{
UserID: userID,
TotalPrice: item.Price * float64(item.Quantity),
Status: entity.CartStatusActive,
CreatedAt: time.Now().UTC(),
ExpireAt: time.Now().Add(d.config.MysqlTTL).UTC(),
}
result, err := tx.ExecContext(ctx, cartQuery,
newCart.UserID, newCart.TotalPrice, newCart.Status, newCart.ExpireAt, newCart.CreatedAt)
if err != nil {
return richerror.New(richerror.Op(op("mysql-addToBasket"))).
WithKind(richerror.KindUnexpected).
WithErr(err)
}
cartID, _ := result.LastInsertId()
item.CartID = types.ID(cartID)
if err := d.addItem(ctx, tx, item); err != nil {
return err
}
if err := d.updateTotalPrice(ctx, tx, types.ID(cartID)); err != nil {
return err
}
}
return err
}
isNew := true
if c.ID > 0 {
for _, i := range c.Items {
if i.ProductID == item.ProductID {
item.Quantity = i.Quantity + item.Quantity
if err := d.updateQuantity(ctx, tx, item); err != nil {
return err
}
isNew = false
}
}
}
if isNew {
err := d.addItem(ctx, tx, item)
if err != nil {
return err
}
}
if err := d.updateTotalPrice(ctx, tx, c.ID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return richerror.New(richerror.Op(op("mysql-addToBasket"))).
WithKind(richerror.KindUnexpected).
WithErr(err)
}
return nil
}
func (d DB) addItem(ctx context.Context, tx *sql.Tx, item entity.Item) error {
t, ok, err := d.getLocalTX(ctx, tx, "mysql-addItem")
if err != nil {
return richerror.New(richerror.Op(op("mysql-addItem"))).
WithKind(richerror.KindUnexpected).
WithErr(err)
}
if ok {
defer func() {
_ = tx.Rollback()
}()
}
query := `INSERT INTO items cart_id, user_id, product_id, quantity, price, name, added_at
VALUES (?,?,?,?,?,?,?)`
_, err = t.ExecContext(ctx, query,
item.CartID, item.UserID, item.ProductID, item.Quantity, item.Price, item.Name, item.AddedAt)
if err != nil {
return richerror.New(richerror.Op(op("mysql-addItem"))).
WithKind(richerror.KindUnexpected).
WithErr(err)
}
if ok {
if err := tx.Commit(); err != nil {
return richerror.New(richerror.Op(op("mysql-addItem"))).
WithKind(richerror.KindUnexpected).
WithErr(err)
}
}
return nil
}
func (d DB) updateQuantity(ctx context.Context, tx *sql.Tx, item entity.Item) error {
t, ok, err := d.getLocalTX(ctx, tx, "mysql-updateQuantity")
if err != nil {
return err
}
if ok {
defer func() {
_ = tx.Rollback()
}()
}
query := `UPDATE items
SET
quantity = ?
WHERE id = ?`
_, err = t.ExecContext(ctx, query, item.Quantity, item.ID)
if err != nil {
return richerror.New(richerror.Op(op("mysql-updateQuantity"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
if ok {
if err := t.Commit(); err != nil {
return richerror.New(richerror.Op(op("mysql-updateQuantity"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
}
return nil
}
func (d DB) updateTotalPrice(ctx context.Context, tx *sql.Tx, cartID types.ID) error {
t, ok, err := d.getLocalTX(ctx, tx, "mysql-updateTotalPrice")
if err != nil {
return err
}
if ok {
defer func() {
_ = tx.Rollback()
}()
}
c, err := d.getCart(ctx, t, FindCartByIDQuery, cartID)
if err != nil {
return err
}
var totalPrice float64
for _, i := range c.Items {
totalPrice += i.Price * float64(i.Quantity)
}
query := `UPDATE carts
SET
total_price = ?
WHERE cart_id=?`
_, err = tx.ExecContext(ctx, query, totalPrice, c.ID)
if err != nil {
return richerror.New(richerror.Op(op("mysql-updateTotalPrice"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
if ok {
if err := tx.Commit(); err != nil {
return richerror.New(richerror.Op(op("mysql-updateTotalPrice"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
}
return nil
}
func (d DB) deleteItem(ctx context.Context, tx *sql.Tx, cartID, itemID types.ID) error {
t, ok, err := d.getLocalTX(ctx, tx, "mysql-deleteItem")
if err != nil {
return err
}
if ok {
defer func() {
_ = tx.Rollback()
}()
}
query := `UPDATE items
SET
deleted_at = ?
WHERE cart_id = ? AND id = ? AND deleted_at IS NULL`
deleteTime := time.Now().UTC()
_, err = t.ExecContext(ctx, query, deleteTime, cartID, itemID)
if err != nil {
return richerror.New(richerror.Op(op("mysql-deleteItem"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
if err := d.updateTotalPrice(ctx, t, cartID); err != nil {
return err
}
if ok {
if err := t.Commit(); err != nil {
return richerror.New(richerror.Op(op("mysql-deleteItem"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
}
return nil
}
func (d DB) deleteCart(ctx context.Context, cartID, userID types.ID) error {
itemQuery := `UPDATE items
SET
deleted_at = ?
WHERE cart_id = ? AND user_id = ? AND deleted_at IS NULL`
deleteItemTime := time.Now().UTC()
_, err := d.db.Conn().ExecContext(ctx, itemQuery, deleteItemTime, cartID, userID)
if err != nil {
return richerror.New(richerror.Op(op("mysql-deleteCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
cartQuery := `UPDATE carts
SET
deleted_at = ?
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`
deleteCartTime := time.Now().UTC()
_, err = d.db.Conn().ExecContext(ctx, cartQuery, deleteCartTime, cartID, userID)
if err != nil {
return richerror.New(richerror.Op(op("mysql-deleteCart"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
return nil
}
func (d DB) updateCartStatus(ctx context.Context, tx *sql.Tx, cartID types.ID, status entity.CartStatus) error {
t, ok, err := d.getLocalTX(ctx, tx, "mysql-updateCartStatus")
if err != nil {
return richerror.New(richerror.Op(op("mysql-updateCartStatus"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
if ok {
defer func() {
_ = t.Rollback()
}()
}
query := `UPDATE carts
SET
status = ?
WHERE id = ? AND status = active AND deleted_at IS NULL`
_, err = t.ExecContext(ctx, query, status, cartID)
if err != nil {
return richerror.New(richerror.Op(op("mysql-updateCartStatus"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
if ok {
if err := t.Commit(); err != nil {
return richerror.New(richerror.Op(op("mysql-updateCartStatus"))).
WithKind(richerror.KindUnexpected).WithErr(err)
}
}
return nil
}
func (d DB) getLocalTX(ctx context.Context, tx *sql.Tx, p string) (*sql.Tx, bool, error) {
var err error
var ok bool
if tx == nil {
tx, err = d.db.Conn().BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
})
if err != nil {
return nil, false, richerror.New(richerror.Op(op(fmt.Sprintf("%s", p)))).
WithKind(richerror.KindUnexpected).
WithErr(err)
}
ok = true
}
return tx, ok, nil
}

View File

@ -0,0 +1,5 @@
package scheduler
// TODO: implement cron job or scheduler
// To check the expiration time in the MYSQL database and change the shopping cart status to expired
// and clear the cart from the cache

View File

@ -3,31 +3,39 @@ package service
import ( import (
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity" "git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
"git.gocasts.ir/ebhomengo/niki/types" "git.gocasts.ir/ebhomengo/niki/types"
"time"
) )
type AddToCartRequest struct { type AddToCartRequest struct {
UserID types.ID `json:"user_id"` UserID types.ID `json:"user_id"`
ProductID types.ID `json:"product_id"` ProductID types.ID `json:"product_id"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
Price types.Price `json:"price"` Price float64 `json:"price"`
Name string `json:"name"` Name string `json:"name"`
} }
type GetCartResponse struct { type GetCartResponse struct {
ID types.ID `json:"id"`
UserID types.ID `json:"user_id"` UserID types.ID `json:"user_id"`
Items []entity.Item `json:"items"` Items []entity.Item `json:"items"`
TotalPrice types.Price `json:"total_price"` TotalPrice float64 `json:"total_price"`
CreatedAt int64 `json:"created_at"` Status string `json:"status"`
ExpireAt int64 `json:"expire_at"` CreatedAt time.Time `json:"created_at"`
ExpireAt time.Time `json:"expire_at"`
} }
type RemoveFromCartRequest struct { type RemoveFromCartRequest struct {
UserID types.ID `json:"user_id"` CartID types.ID `json:"cart_id"`
ProductID types.ID `json:"product_id"` ItemID types.ID `json:"item_id"`
} }
type UpdateQuantityRequest struct { type UpdateQuantityRequest struct {
UserID types.ID `json:"user_id"` CartID types.ID `json:"cart_id"`
ProductID types.ID `json:"product_id"` ItemID types.ID `json:"item_id"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
} }
type UpdateCartStatusRequest struct {
CartID types.ID `json:"cart_id"`
Status entity.CartStatus `json:"status"`
}

View File

@ -12,9 +12,10 @@ import (
type Repository interface { type Repository interface {
AddItem(ctx context.Context, userID types.ID, item entity.Item) error AddItem(ctx context.Context, userID types.ID, item entity.Item) error
GetCart(ctx context.Context, userID types.ID) (entity.Cart, error) GetCart(ctx context.Context, userID types.ID) (entity.Cart, error)
DeleteItem(ctx context.Context, userID, productID types.ID) error DeleteItem(ctx context.Context, cartID, itemID types.ID) error
UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error UpdateQuantity(ctx context.Context, cartID, itemID types.ID, quantity int) error
DeleteCart(ctx context.Context, userID types.ID) error DeleteCart(ctx context.Context, cartID, userID types.ID) error
UpdateStatus(ctx context.Context, cartID types.ID, status entity.CartStatus) error
} }
type Service struct { type Service struct {
@ -39,7 +40,7 @@ func (s Service) AddToBasket(ctx context.Context, req AddToCartRequest) error {
Quantity: req.Quantity, Quantity: req.Quantity,
Price: req.Price, Price: req.Price,
Name: req.Name, Name: req.Name,
AddedAt: time.Now().UnixNano(), AddedAt: time.Now().UTC(),
}) })
} }
@ -57,9 +58,11 @@ func (s Service) GetCart(ctx context.Context, userID types.ID) (GetCartResponse,
} }
return GetCartResponse{ return GetCartResponse{
ID: res.ID,
UserID: res.UserID, UserID: res.UserID,
Items: res.Items, Items: res.Items,
TotalPrice: res.TotalPrice, TotalPrice: res.TotalPrice,
Status: string(res.Status),
CreatedAt: res.CreatedAt, CreatedAt: res.CreatedAt,
ExpireAt: res.ExpireAt, ExpireAt: res.ExpireAt,
}, nil }, nil
@ -73,7 +76,7 @@ func (s Service) RemoveFromCart(ctx context.Context, req RemoveFromCartRequest)
return err return err
} }
return s.repo.DeleteItem(ctx, req.UserID, req.ProductID) return s.repo.DeleteItem(ctx, req.CartID, req.ItemID)
} }
func (s Service) UpdateQuantity(ctx context.Context, req UpdateQuantityRequest) error { func (s Service) UpdateQuantity(ctx context.Context, req UpdateQuantityRequest) error {
@ -85,13 +88,13 @@ func (s Service) UpdateQuantity(ctx context.Context, req UpdateQuantityRequest)
} }
if req.Quantity == 0 { if req.Quantity == 0 {
return s.repo.DeleteItem(ctx, req.UserID, req.ProductID) return s.repo.DeleteItem(ctx, req.CartID, req.ItemID)
} }
return s.repo.UpdateQuantity(ctx, req.UserID, req.ProductID, req.Quantity) return s.repo.UpdateQuantity(ctx, req.CartID, req.ItemID, req.Quantity)
} }
func (s Service) ClearCart(ctx context.Context, userID types.ID) error { func (s Service) ClearCart(ctx context.Context, cartID, userID types.ID) error {
const op = "shoppingbaskerapp.service.ClearCart" const op = "shoppingbaskerapp.service.ClearCart"
if userID < 1 { if userID < 1 {
@ -100,5 +103,14 @@ func (s Service) ClearCart(ctx context.Context, userID types.ID) error {
WithMessage("invalid user id") WithMessage("invalid user id")
} }
return s.repo.DeleteCart(ctx, userID) return s.repo.DeleteCart(ctx, cartID, userID)
}
func (s Service) UpdateCartStatus(ctx context.Context, req UpdateCartStatusRequest) error {
const op = "shoppingbaskerapp.service.ClearCart"
if !req.Status.IsValid() {
return richerror.New(op).WithKind(richerror.KindInvalid).WithMessage("invalid status")
}
return s.repo.UpdateStatus(ctx, req.CartID, req.Status)
} }

View File

@ -47,8 +47,8 @@ func (v Validate) ValidateRemoveFromCart(req RemoveFromCartRequest) error {
const op = "shoppingbasketapp.service.ValidateRemoveFromCart" const op = "shoppingbasketapp.service.ValidateRemoveFromCart"
if err := validation.ValidateStruct(&req, if err := validation.ValidateStruct(&req,
validation.Field(&req.UserID, validation.Required), validation.Field(&req.CartID, validation.Required),
validation.Field(&req.ProductID, validation.Required)); err != nil { validation.Field(&req.ItemID, validation.Required)); err != nil {
fieldErrs := make(map[string]interface{}) fieldErrs := make(map[string]interface{})
vErr, ok := err.(validation.Errors) vErr, ok := err.(validation.Errors)
@ -70,8 +70,8 @@ func (v Validate) ValidateUpdateQuantity(req UpdateQuantityRequest) error {
const op = "shoppingbasketapp.service.ValidateUpdateQuantity" const op = "shoppingbasketapp.service.ValidateUpdateQuantity"
if err := validation.ValidateStruct(&req, if err := validation.ValidateStruct(&req,
validation.Field(&req.UserID, validation.Required), validation.Field(&req.CartID, validation.Required),
validation.Field(&req.ProductID, validation.Required), validation.Field(&req.ItemID, validation.Required),
validation.Field(&req.Quantity, validation.Required, validation.Min(int(1)))); err != nil { validation.Field(&req.Quantity, validation.Required, validation.Min(int(1)))); err != nil {
fieldErrs := make(map[string]interface{}) fieldErrs := make(map[string]interface{})

View File

@ -6,6 +6,7 @@ import (
"git.gocasts.ir/ebhomengo/niki/adapter/redis" "git.gocasts.ir/ebhomengo/niki/adapter/redis"
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/repository" "git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/repository"
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/service" "git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/service"
mysql2 "git.gocasts.ir/ebhomengo/niki/pkg/database/mysql"
"git.gocasts.ir/ebhomengo/niki/pkg/httpserver" "git.gocasts.ir/ebhomengo/niki/pkg/httpserver"
"git.gocasts.ir/ebhomengo/niki/pkg/logger" "git.gocasts.ir/ebhomengo/niki/pkg/logger"
"git.gocasts.ir/ebhomengo/niki/shoppingbasketapp/delivery/http" "git.gocasts.ir/ebhomengo/niki/shoppingbasketapp/delivery/http"
@ -27,7 +28,12 @@ type Application struct {
func Setup(ctx context.Context, cfg Config) (Application, error) { func Setup(ctx context.Context, cfg Config) (Application, error) {
adapter := redis.New(cfg.Redis) adapter := redis.New(cfg.Redis)
repo := repository.New(adapter.Client(), cfg.Repo) cache := repository.NewCache(adapter, cfg.Repo)
mysql := mysql2.New(cfg.Mysql)
db := repository.NewDB(mysql, cfg.Repo)
repo := repository.New(db, cache)
validator := service.NewValidate() validator := service.NewValidate()
svc := service.New(validator, repo) svc := service.New(validator, repo)

View File

@ -3,13 +3,16 @@ package shoppingbasketapp
import ( import (
"git.gocasts.ir/ebhomengo/niki/adapter/redis" "git.gocasts.ir/ebhomengo/niki/adapter/redis"
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/repository" "git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/repository"
"git.gocasts.ir/ebhomengo/niki/pkg/database/mysql"
"git.gocasts.ir/ebhomengo/niki/pkg/httpserver" "git.gocasts.ir/ebhomengo/niki/pkg/httpserver"
logger "git.gocasts.ir/ebhomengo/niki/pkg/logger" logger "git.gocasts.ir/ebhomengo/niki/pkg/logger"
) )
type Config struct { type Config struct {
Redis redis.Config `koanf:"redis" json:"redis"` Redis redis.Config `koanf:"redis" json:"redis"`
Mysql mysql.Config `koanf:"mysql" json:"mysql"`
Repo repository.Config `koanf:"repo" json:"repo"` Repo repository.Config `koanf:"repo" json:"repo"`
HTTPServer httpserver.Config `koanf:"http_server" json:"http_server"` HTTPServer httpserver.Config `koanf:"http_server" json:"http_server"`
Logger logger.Config `koanf:"logger" json:"logger"` Logger logger.Config `koanf:"logger" json:"logger"`
PathOfMigration string `koanf:"path_of_migration" json:"path_of_migration"`
} }

View File

@ -0,0 +1,5 @@
package consumer
// TODO: implement subscriber or consumer
// When the shopping cart is successfully paid
// the service waits for a message in the broker to change the cart status to paid and clear it from the cache.

View File

@ -52,7 +52,21 @@ func (h Handler) GetCart(c echo.Context) error {
func (h Handler) RemoveCart(c echo.Context) error { func (h Handler) RemoveCart(c echo.Context) error {
claims := claim.GetClaimsFromEchoContext(c) claims := claim.GetClaimsFromEchoContext(c)
if err := h.svc.ClearCart(c.Request().Context(), types.ID(claims.UserID)); err != nil { cartIDStr := c.Param("cart_id")
if cartIDStr == "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "cart id required",
})
}
cartID, err := strconv.Atoi(cartIDStr)
if err != nil || cartID < 1 {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid cart id",
})
}
if err := h.svc.ClearCart(c.Request().Context(), types.ID(cartID), types.ID(claims.UserID)); err != nil {
msg, code := httpmsg.Error(err) msg, code := httpmsg.Error(err)
return c.JSON(code, msg) return c.JSON(code, msg)
} }
@ -61,10 +75,28 @@ func (h Handler) RemoveCart(c echo.Context) error {
} }
func (h Handler) RemoveItem(c echo.Context) error { func (h Handler) RemoveItem(c echo.Context) error {
claims := claim.GetClaimsFromEchoContext(c) cartIDStr := c.Param("cart_id")
p := c.Param("productID") itemIDStr := c.Param("item_id")
pID, err := strconv.Atoi(p) if cartIDStr == "" {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": "cart id required",
})
}
cID, err := strconv.Atoi(cartIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid product id",
})
}
if itemIDStr == "" {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": "item id required",
})
}
iID, err := strconv.Atoi(itemIDStr)
if err != nil { if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{ return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid product id", "error": "invalid product id",
@ -73,8 +105,8 @@ func (h Handler) RemoveItem(c echo.Context) error {
var req service.RemoveFromCartRequest var req service.RemoveFromCartRequest
req.UserID = types.ID(claims.UserID) req.CartID = types.ID(cID)
req.ProductID = types.ID(pID) req.ItemID = types.ID(iID)
if err := h.svc.RemoveFromCart(c.Request().Context(), req); err != nil { if err := h.svc.RemoveFromCart(c.Request().Context(), req); err != nil {
msg, code := httpmsg.Error(err) msg, code := httpmsg.Error(err)
@ -85,28 +117,12 @@ func (h Handler) RemoveItem(c echo.Context) error {
} }
func (h Handler) UpdateQuantity(c echo.Context) error { func (h Handler) UpdateQuantity(c echo.Context) error {
claims := claim.GetClaimsFromEchoContext(c)
p := c.Param("productID")
pID, err := strconv.Atoi(p)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid product id",
})
}
qStr := c.Param("quantity")
q, err := strconv.Atoi(qStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid quantity",
})
}
var req service.UpdateQuantityRequest var req service.UpdateQuantityRequest
req.UserID = types.ID(claims.UserID) if err := c.Bind(&req); err != nil {
req.ProductID = types.ID(pID) return c.JSON(http.StatusBadRequest, map[string]string{
req.Quantity = q "error": "invalid request body",
})
}
if err := h.svc.UpdateQuantity(c.Request().Context(), req); err != nil { if err := h.svc.UpdateQuantity(c.Request().Context(), req); err != nil {
msg, code := httpmsg.Error(err) msg, code := httpmsg.Error(err)
@ -115,3 +131,19 @@ func (h Handler) UpdateQuantity(c echo.Context) error {
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
} }
func (h Handler) UpdateCartStatus(c echo.Context) error {
var req service.UpdateCartStatusRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request body",
})
}
if err := h.svc.UpdateCartStatus(c.Request().Context(), req); err != nil {
msg, code := httpmsg.Error(err)
return c.JSON(code, msg)
}
return c.NoContent(http.StatusNoContent)
}

View File

@ -33,12 +33,13 @@ func (s Server) registerRoutes() {
router.GET("shoppingbasket/health-check", s.healthCheck) router.GET("shoppingbasket/health-check", s.healthCheck)
r := router.Group("shoppingbasket/cart") // Authentication is required r := router.Group("shoppingbasket/carts") // Authentication is required
r.GET("/", s.handler.GetCart) r.GET("/", s.handler.GetCart)
r.DELETE("/", s.handler.RemoveCart) r.DELETE("/:cart_id", s.handler.RemoveCart)
r.PUT("/status", s.handler.UpdateCartStatus)
r.POST("/items", s.handler.AddToBasket) r.POST("/items", s.handler.AddToBasket)
r.DELETE("/items/:productID", s.handler.RemoveItem) r.DELETE("/items/:cart_id/:item_id", s.handler.RemoveItem)
r.PUT("/items/:productID/:quantity", s.handler.UpdateQuantity) r.PUT("/items/item-quantity", s.handler.UpdateQuantity)
} }

2
vendor/modules.txt vendored
View File

@ -149,6 +149,8 @@ github.com/knadh/koanf/parsers/yaml
github.com/knadh/koanf/providers/env github.com/knadh/koanf/providers/env
github.com/knadh/koanf/providers/file github.com/knadh/koanf/providers/file
github.com/knadh/koanf/providers/structs github.com/knadh/koanf/providers/structs
# github.com/knadh/koanf/v2 v2.3.0
## explicit; go 1.23.0
# github.com/labstack/echo-jwt/v4 v4.4.0 # github.com/labstack/echo-jwt/v4 v4.4.0
## explicit; go 1.24.0 ## explicit; go 1.24.0
github.com/labstack/echo-jwt/v4 github.com/labstack/echo-jwt/v4