Compare commits

...

10 Commits

23 changed files with 1094 additions and 357 deletions

View File

@ -3,9 +3,9 @@ package main
import (
"flag"
"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/service/order"
"git.gocasts.ir/ebhomengo/niki/repository/migrator"
"git.gocasts.ir/ebhomengo/niki/repository/mysql"
)

View File

@ -1 +1,52 @@
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 {
yamlPath = defaultConfig
} 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 {
log.Fatalf("Failed to load benefactor config: %v", err)
log.Fatalf("Failed to load shoppingbasket config: %v", err)
}
return cfg

View File

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

View File

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

View File

@ -2,20 +2,46 @@ package entity
import (
"git.gocasts.ir/ebhomengo/niki/types"
"time"
)
type CartStatus string
type Item struct {
ID types.ID
CartID types.ID
ProductID types.ID
UserID types.ID
Quantity int
Price types.Price
Price float64
Name string
AddedAt int64
AddedAt time.Time
UpdatedAt time.Time
}
type Cart struct {
ID types.ID
UserID types.ID
Items []Item
TotalPrice types.Price
ExpireAt int64
CreatedAt int64
Status CartStatus
TotalPrice float64
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 (
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
"git.gocasts.ir/ebhomengo/niki/types"
"time"
)
type AddToCartRequest struct {
UserID types.ID `json:"user_id"`
ProductID types.ID `json:"product_id"`
Quantity int `json:"quantity"`
Price types.Price `json:"price"`
Name string `json:"name"`
}
type GetCartResponse struct {
UserID types.ID `json:"user_id"`
Items []entity.Item `json:"items"`
TotalPrice types.Price `json:"total_price"`
CreatedAt int64 `json:"created_at"`
ExpireAt int64 `json:"expire_at"`
}
type RemoveFromCartRequest struct {
UserID types.ID `json:"user_id"`
ProductID types.ID `json:"product_id"`
}
type UpdateQuantityRequest struct {
UserID types.ID `json:"user_id"`
ProductID types.ID `json:"product_id"`
Quantity int `json:"quantity"`
Price float64 `json:"price"`
Name string `json:"name"`
}
type GetCartResponse struct {
ID types.ID `json:"id"`
UserID types.ID `json:"user_id"`
Items []entity.Item `json:"items"`
TotalPrice float64 `json:"total_price"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ExpireAt time.Time `json:"expire_at"`
}
type RemoveFromCartRequest struct {
CartID types.ID `json:"cart_id"`
ItemID types.ID `json:"item_id"`
}
type UpdateQuantityRequest struct {
CartID types.ID `json:"cart_id"`
ItemID types.ID `json:"item_id"`
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 {
AddItem(ctx context.Context, userID types.ID, item entity.Item) error
GetCart(ctx context.Context, userID types.ID) (entity.Cart, error)
DeleteItem(ctx context.Context, userID, productID types.ID) error
UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error
DeleteCart(ctx context.Context, userID types.ID) error
DeleteItem(ctx context.Context, cartID, itemID types.ID) error
UpdateQuantity(ctx context.Context, cartID, itemID types.ID, quantity int) error
DeleteCart(ctx context.Context, cartID, userID types.ID) error
UpdateStatus(ctx context.Context, cartID types.ID, status entity.CartStatus) error
}
type Service struct {
@ -39,7 +40,7 @@ func (s Service) AddToBasket(ctx context.Context, req AddToCartRequest) error {
Quantity: req.Quantity,
Price: req.Price,
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{
ID: res.ID,
UserID: res.UserID,
Items: res.Items,
TotalPrice: res.TotalPrice,
Status: string(res.Status),
CreatedAt: res.CreatedAt,
ExpireAt: res.ExpireAt,
}, nil
@ -73,7 +76,7 @@ func (s Service) RemoveFromCart(ctx context.Context, req RemoveFromCartRequest)
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 {
@ -85,13 +88,13 @@ func (s Service) UpdateQuantity(ctx context.Context, req UpdateQuantityRequest)
}
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"
if userID < 1 {
@ -100,5 +103,14 @@ func (s Service) ClearCart(ctx context.Context, userID types.ID) error {
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"
if err := validation.ValidateStruct(&req,
validation.Field(&req.UserID, validation.Required),
validation.Field(&req.ProductID, validation.Required)); err != nil {
validation.Field(&req.CartID, validation.Required),
validation.Field(&req.ItemID, validation.Required)); err != nil {
fieldErrs := make(map[string]interface{})
vErr, ok := err.(validation.Errors)
@ -70,8 +70,8 @@ func (v Validate) ValidateUpdateQuantity(req UpdateQuantityRequest) error {
const op = "shoppingbasketapp.service.ValidateUpdateQuantity"
if err := validation.ValidateStruct(&req,
validation.Field(&req.UserID, validation.Required),
validation.Field(&req.ProductID, validation.Required),
validation.Field(&req.CartID, validation.Required),
validation.Field(&req.ItemID, validation.Required),
validation.Field(&req.Quantity, validation.Required, validation.Min(int(1)))); err != nil {
fieldErrs := make(map[string]interface{})

View File

@ -6,6 +6,7 @@ import (
"git.gocasts.ir/ebhomengo/niki/adapter/redis"
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/repository"
"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/logger"
"git.gocasts.ir/ebhomengo/niki/shoppingbasketapp/delivery/http"
@ -27,7 +28,12 @@ type Application struct {
func Setup(ctx context.Context, cfg Config) (Application, error) {
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()
svc := service.New(validator, repo)

View File

@ -3,13 +3,16 @@ package shoppingbasketapp
import (
"git.gocasts.ir/ebhomengo/niki/adapter/redis"
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/repository"
"git.gocasts.ir/ebhomengo/niki/pkg/database/mysql"
"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"`
Redis redis.Config `koanf:"redis" json:"redis"`
Mysql mysql.Config `koanf:"mysql" json:"mysql"`
Repo repository.Config `koanf:"repo" json:"repo"`
HTTPServer httpserver.Config `koanf:"http_server" json:"http_server"`
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 {
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)
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 {
claims := claim.GetClaimsFromEchoContext(c)
p := c.Param("productID")
cartIDStr := c.Param("cart_id")
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 {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid product id",
@ -73,8 +105,8 @@ func (h Handler) RemoveItem(c echo.Context) error {
var req service.RemoveFromCartRequest
req.UserID = types.ID(claims.UserID)
req.ProductID = types.ID(pID)
req.CartID = types.ID(cID)
req.ItemID = types.ID(iID)
if err := h.svc.RemoveFromCart(c.Request().Context(), req); err != nil {
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 {
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
req.UserID = types.ID(claims.UserID)
req.ProductID = types.ID(pID)
req.Quantity = q
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request body",
})
}
if err := h.svc.UpdateQuantity(c.Request().Context(), req); err != nil {
msg, code := httpmsg.Error(err)
@ -115,3 +131,19 @@ func (h Handler) UpdateQuantity(c echo.Context) error {
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)
r := router.Group("shoppingbasket/cart") // Authentication is required
r := router.Group("shoppingbasket/carts") // Authentication is required
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.DELETE("/items/:productID", s.handler.RemoveItem)
r.PUT("/items/:productID/:quantity", s.handler.UpdateQuantity)
r.DELETE("/items/:cart_id/:item_id", s.handler.RemoveItem)
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/file
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
## explicit; go 1.24.0
github.com/labstack/echo-jwt/v4