diff --git a/domain/shoppingbasket/entity/entity.go b/domain/shoppingbasket/entity/entity.go index 99cc6c96..b1a53f52 100644 --- a/domain/shoppingbasket/entity/entity.go +++ b/domain/shoppingbasket/entity/entity.go @@ -2,20 +2,36 @@ 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" +) diff --git a/domain/shoppingbasket/repository/cart.go b/domain/shoppingbasket/repository/cart.go index 7e0636ee..b007bb60 100644 --- a/domain/shoppingbasket/repository/cart.go +++ b/domain/shoppingbasket/repository/cart.go @@ -35,6 +35,10 @@ func New(client *redis.Client, cfg Config) Repo { return Repo{client: client, config: cfg} } +func op(s string) string { + return fmt.Sprintf("shoppingbasketapp-repository-", s) +} + func (r Repo) cartKey(userID types.ID) string { return r.config.KartKeyPrefix + fmt.Sprintf("%d", userID) } diff --git a/domain/shoppingbasket/repository/constant.go b/domain/shoppingbasket/repository/constant.go new file mode 100644 index 00000000..e9ed1943 --- /dev/null +++ b/domain/shoppingbasket/repository/constant.go @@ -0,0 +1,18 @@ +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` + + 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") +) diff --git a/domain/shoppingbasket/repository/migration/00001-create-cart-table.sql b/domain/shoppingbasket/repository/migration/00001-create-cart-table.sql index e6441656..1129985a 100644 --- a/domain/shoppingbasket/repository/migration/00001-create-cart-table.sql +++ b/domain/shoppingbasket/repository/migration/00001-create-cart-table.sql @@ -1,15 +1,19 @@ -- +migrate Up CREATE TABLE `carts` ( - `id` INT PRIMARY KEY AUTO_INCREMENT, - `user_id` INT NOT NULL, + `id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, `total_price` DECIMAL(10,2) DEFAULT 0, - `expire_at` TIMESTAMP NULL, + `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`; diff --git a/domain/shoppingbasket/repository/migration/00002-create-table-item.sql b/domain/shoppingbasket/repository/migration/00002-create-table-item.sql index 9e0f13e5..ddabb653 100644 --- a/domain/shoppingbasket/repository/migration/00002-create-table-item.sql +++ b/domain/shoppingbasket/repository/migration/00002-create-table-item.sql @@ -1,13 +1,16 @@ -- +migrate Up CREATE TABLE `items` ( - `id` INT PRIMARY KEY AUTO_INCREMENT, - `cart_id` INT NOT NULL, - `product_id` INT NOT NULL, + `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), + `name` VARCHAR(256) NOT NULL , `added_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`cart_id`) REFERENCES `cart`(`id`) + `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`); diff --git a/domain/shoppingbasket/repository/mysql.go b/domain/shoppingbasket/repository/mysql.go index d60117b5..1a043329 100644 --- a/domain/shoppingbasket/repository/mysql.go +++ b/domain/shoppingbasket/repository/mysql.go @@ -1,15 +1,408 @@ package repository -import querier "git.gocasts.ir/ebhomengo/niki/pkg/query_transaction/sql" +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 *querier.SQLDB + db *mysql.DB + config Config } -func NewDB(db *querier.SQLDB) DB { +func NewDB(db *mysql.DB) DB { return DB{db: db} } -func (d DB) addToBasket() { +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.TTL).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, productID 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 product_id = ? AND deleted_at IS NULL` + deleteTime := time.Now().UTC() + _, err = t.ExecContext(ctx, query, deleteTime, cartID, productID) + 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 { + query := `UPDATE items + SET + deleted_at = ? + WHERE cart_id = ? AND user_id = ? AND deleted_at IS NULL` + deleteTime := time.Now().UTC() + _, err := d.db.Conn().ExecContext(ctx, query, deleteTime, 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 cart_id = ? 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 } diff --git a/domain/shoppingbasket/service/service.go b/domain/shoppingbasket/service/service.go index 78a2dd1e..e8436e50 100644 --- a/domain/shoppingbasket/service/service.go +++ b/domain/shoppingbasket/service/service.go @@ -12,9 +12,9 @@ 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 + DeleteItem(ctx context.Context, cartID, itemID types.ID) error UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error - DeleteCart(ctx context.Context, userID types.ID) error + DeleteCart(ctx context.Context, cartID types.ID) error } type Service struct {