fix: auto commit to DB

This commit is contained in:
pahmiudahgede 2025-05-29 16:44:47 +07:00
parent 29b04db0cb
commit 58f843cac2
17 changed files with 627 additions and 286 deletions

View File

@ -2,58 +2,49 @@ package main
import ( import (
"log" "log"
"os"
"rijig/config" "rijig/config"
"rijig/internal/repositories"
"rijig/internal/services"
"rijig/internal/worker" "rijig/internal/worker"
"rijig/router" "rijig/router"
"time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/cors"
"github.com/robfig/cron"
) )
func main() { func main() {
config.SetupConfig() config.SetupConfig()
logFile, _ := os.OpenFile("logs/cart_commit.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) cartRepo := repositories.NewCartRepository()
log.SetOutput(logFile) trashRepo := repositories.NewTrashRepository(config.DB)
cartService := services.NewCartService(cartRepo, trashRepo)
worker := worker.NewCartWorker(cartService, cartRepo, trashRepo)
go func() { go func() {
c := cron.New() ticker := time.NewTicker(30 * time.Second)
c.AddFunc("@every 1m", func() { defer ticker.Stop()
_ = worker.CommitExpiredCartsToDB()
}) for range ticker.C {
c.Start() if err := worker.AutoCommitExpiringCarts(); err != nil {
log.Printf("Auto-commit error: %v", err)
}
}
}() }()
app := fiber.New()
app.Use(cors.New(cors.Config{ app := fiber.New(fiber.Config{
AllowOrigins: "*", ErrorHandler: func(c *fiber.Ctx, err error) error {
AllowMethods: "GET,POST,PUT,PATCH,DELETE", code := fiber.StatusInternalServerError
AllowHeaders: "Content-Type,x-api-key", if e, ok := err.(*fiber.Error); ok {
})) code = e.Code
}
return c.Status(code).JSON(fiber.Map{
"error": err.Error(),
})
},
})
// app.Use(cors.New(cors.Config{ app.Use(cors.New())
// AllowOrigins: "http://localhost:3000",
// AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
// AllowHeaders: "Origin, Content-Type, Accept, Authorization, x-api-key",
// AllowCredentials: true,
// }))
// app.Use(func(c *fiber.Ctx) error {
// c.Set("Access-Control-Allow-Origin", "http://localhost:3000")
// c.Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
// c.Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, x-api-key")
// c.Set("Access-Control-Allow-Credentials", "true")
// return c.Next()
// })
// app.Options("*", func(c *fiber.Ctx) error {
// c.Set("Access-Control-Allow-Origin", "http://localhost:3000")
// c.Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
// c.Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, x-api-key")
// c.Set("Access-Control-Allow-Credentials", "true")
// return c.SendStatus(fiber.StatusNoContent)
// })
router.SetupRoutes(app) router.SetupRoutes(app)
config.StartServer(app) config.StartServer(app)

View File

@ -7,44 +7,38 @@ import (
type RequestCartItemDTO struct { type RequestCartItemDTO struct {
TrashID string `json:"trash_id"` TrashID string `json:"trash_id"`
Amount float32 `json:"amount"` Amount float64 `json:"amount"`
} }
type RequestCartDTO struct { type RequestCartDTO struct {
CartItems []RequestCartItemDTO `json:"cart_items"` CartItems []RequestCartItemDTO `json:"cart_items"`
} }
type ResponseCartDTO struct {
ID string `json:"id"`
UserID string `json:"user_id"`
TotalAmount float64 `json:"total_amount"`
EstimatedTotalPrice float64 `json:"estimated_total_price"`
CartItems []ResponseCartItemDTO `json:"cart_items"`
}
type ResponseCartItemDTO struct { type ResponseCartItemDTO struct {
ID string `json:"id"` ID string `json:"id"`
TrashID string `json:"trash_id"` TrashID string `json:"trash_id"`
TrashName string `json:"trash_name"` TrashName string `json:"trash_name"`
TrashIcon string `json:"trash_icon"` TrashIcon string `json:"trash_icon"`
Amount float32 `json:"amount"` TrashPrice float64 `json:"trash_price"`
SubTotalEstimatedPrice float32 `json:"subtotal_estimated_price"` Amount float64 `json:"amount"`
} SubTotalEstimatedPrice float64 `json:"subtotal_estimated_price"`
type ResponseCartDTO struct {
ID string `json:"id"`
UserID string `json:"user_id"`
TotalAmount float32 `json:"total_amount"`
EstimatedTotalPrice float32 `json:"estimated_total_price"`
CartItems []ResponseCartItemDTO `json:"cart_items"`
} }
func (r *RequestCartDTO) ValidateRequestCartDTO() (map[string][]string, bool) { func (r *RequestCartDTO) ValidateRequestCartDTO() (map[string][]string, bool) {
errors := make(map[string][]string) errors := make(map[string][]string)
if len(r.CartItems) == 0 {
errors["cart_items"] = append(errors["cart_items"], "minimal satu item harus dimasukkan")
}
for i, item := range r.CartItems { for i, item := range r.CartItems {
if strings.TrimSpace(item.TrashID) == "" { if strings.TrimSpace(item.TrashID) == "" {
errors[fmt.Sprintf("cart_items[%d].trash_id", i)] = append(errors[fmt.Sprintf("cart_items[%d].trash_id", i)], "trash_id tidak boleh kosong") errors[fmt.Sprintf("cart_items[%d].trash_id", i)] = append(errors[fmt.Sprintf("cart_items[%d].trash_id", i)], "trash_id tidak boleh kosong")
} }
if item.Amount <= 0 {
errors[fmt.Sprintf("cart_items[%d].amount", i)] = append(errors[fmt.Sprintf("cart_items[%d].amount", i)], "amount harus lebih dari 0")
}
} }
if len(errors) > 0 { if len(errors) > 0 {

1
go.mod
View File

@ -37,7 +37,6 @@ require (
github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mdp/qrterminal/v3 v3.2.0 github.com/mdp/qrterminal/v3 v3.2.0
github.com/rivo/uniseg v0.2.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect
github.com/robfig/cron v1.2.0
github.com/rs/zerolog v1.33.0 // indirect github.com/rs/zerolog v1.33.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect

View File

@ -1,7 +1,6 @@
package handler package handler
import ( import (
"context"
"rijig/dto" "rijig/dto"
"rijig/internal/services" "rijig/internal/services"
"rijig/utils" "rijig/utils"
@ -9,100 +8,86 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
type CartHandler interface { type CartHandler struct {
GetCart(c *fiber.Ctx) error cartService services.CartService
AddOrUpdateCartItem(c *fiber.Ctx) error
AddMultipleCartItems(c *fiber.Ctx) error
DeleteCartItem(c *fiber.Ctx) error
ClearCart(c *fiber.Ctx) error
} }
type cartHandler struct { func NewCartHandler(cartService services.CartService) *CartHandler {
service services.CartService return &CartHandler{cartService: cartService}
} }
func NewCartHandler(service services.CartService) CartHandler { func (h *CartHandler) AddOrUpdateItem(c *fiber.Ctx) error {
return &cartHandler{service: service}
}
func (h *cartHandler) GetCart(c *fiber.Ctx) error {
userID := c.Locals("userID").(string) userID := c.Locals("userID").(string)
var req dto.RequestCartItemDTO
cart, err := h.service.GetCart(context.Background(), userID) if err := c.BodyParser(&req); err != nil {
if err != nil {
return utils.ErrorResponse(c, "Cart belum dibuat atau sudah kadaluarsa")
}
return utils.SuccessResponse(c, cart, "Data cart berhasil diambil")
}
func (h *cartHandler) AddOrUpdateCartItem(c *fiber.Ctx) error {
userID := c.Locals("userID").(string)
var item dto.RequestCartItemDTO
if err := c.BodyParser(&item); err != nil {
return utils.ValidationErrorResponse(c, map[string][]string{"body": {"format tidak valid"}})
}
if item.TrashID == "" || item.Amount <= 0 {
return utils.ValidationErrorResponse(c, map[string][]string{ return utils.ValidationErrorResponse(c, map[string][]string{
"trash_id": {"harus diisi"}, "request": {"Payload tidak valid"},
"amount": {"harus lebih dari 0"},
}) })
} }
if err := h.service.AddOrUpdateItem(context.Background(), userID, item); err != nil { hasErrors, _ := req.Amount > 0 && req.TrashID != "", true
return utils.InternalServerErrorResponse(c, err.Error()) if !hasErrors {
} errs := make(map[string][]string)
if req.Amount <= 0 {
return utils.SuccessResponse(c, nil, "Item berhasil ditambahkan/diupdate di cart") errs["amount"] = append(errs["amount"], "Amount harus lebih dari 0")
} }
if req.TrashID == "" {
func (h *cartHandler) AddMultipleCartItems(c *fiber.Ctx) error { errs["trash_id"] = append(errs["trash_id"], "Trash ID tidak boleh kosong")
userID := c.Locals("userID").(string) }
var payload dto.RequestCartDTO
if err := c.BodyParser(&payload); err != nil {
return utils.ValidationErrorResponse(c, map[string][]string{
"body": {"format tidak valid"},
})
}
if errs, ok := payload.ValidateRequestCartDTO(); !ok {
return utils.ValidationErrorResponse(c, errs) return utils.ValidationErrorResponse(c, errs)
} }
for _, item := range payload.CartItems { if err := h.cartService.AddOrUpdateItem(c.Context(), userID, req); err != nil {
if err := h.service.AddOrUpdateItem(context.Background(), userID, item); err != nil { return utils.InternalServerErrorResponse(c, "Gagal menambahkan item ke keranjang")
return utils.InternalServerErrorResponse(c, err.Error())
}
} }
return utils.SuccessResponse(c, nil, "Semua item berhasil ditambahkan/diupdate ke cart") return utils.GenericResponse(c, fiber.StatusOK, "Item berhasil ditambahkan ke keranjang")
} }
func (h *cartHandler) DeleteCartItem(c *fiber.Ctx) error { func (h *CartHandler) GetCart(c *fiber.Ctx) error {
userID := c.Locals("userID").(string) userID := c.Locals("userID").(string)
trashID := c.Params("trashID")
cart, err := h.cartService.GetCart(c.Context(), userID)
if err != nil {
return utils.ErrorResponse(c, "Gagal mengambil data keranjang")
}
return utils.SuccessResponse(c, cart, "Berhasil mengambil data keranjang")
}
func (h *CartHandler) DeleteItem(c *fiber.Ctx) error {
userID := c.Locals("userID").(string)
trashID := c.Params("trash_id")
if trashID == "" { if trashID == "" {
return utils.ValidationErrorResponse(c, map[string][]string{"trash_id": {"tidak boleh kosong"}}) return utils.GenericResponse(c, fiber.StatusBadRequest, "Trash ID tidak boleh kosong")
} }
err := h.service.DeleteItem(context.Background(), userID, trashID) if err := h.cartService.DeleteItem(c.Context(), userID, trashID); err != nil {
if err != nil { return utils.InternalServerErrorResponse(c, "Gagal menghapus item dari keranjang")
return utils.InternalServerErrorResponse(c, err.Error())
} }
return utils.SuccessResponse(c, nil, "Item berhasil dihapus dari cart") return utils.GenericResponse(c, fiber.StatusOK, "Item berhasil dihapus dari keranjang")
} }
func (h *cartHandler) ClearCart(c *fiber.Ctx) error { func (h *CartHandler) Checkout(c *fiber.Ctx) error {
userID := c.Locals("userID").(string) userID := c.Locals("userID").(string)
if err := h.service.ClearCart(context.Background(), userID); err != nil { if err := h.cartService.Checkout(c.Context(), userID); err != nil {
return utils.InternalServerErrorResponse(c, err.Error()) return utils.InternalServerErrorResponse(c, "Gagal melakukan checkout keranjang")
} }
return utils.SuccessResponse(c, nil, "Seluruh cart berhasil dihapus") return utils.GenericResponse(c, fiber.StatusOK, "Checkout berhasil. Permintaan pickup telah dibuat.")
}
func (h *CartHandler) ClearCart(c *fiber.Ctx) error {
userID := c.Locals("userID").(string)
err := h.cartService.ClearCart(c.Context(), userID)
if err != nil {
return utils.InternalServerErrorResponse(c, "Gagal menghapus keranjang")
}
return utils.GenericResponse(c, fiber.StatusOK, "Keranjang berhasil dikosongkan")
} }

View File

@ -1,15 +1,27 @@
package repositories package repositories
import ( import (
"context"
"errors"
"fmt"
"rijig/config" "rijig/config"
"rijig/model" "rijig/model"
"gorm.io/gorm"
) )
type CartRepository interface { type CartRepository interface {
CreateCart(cart *model.Cart) error FindOrCreateCart(ctx context.Context, userID string) (*model.Cart, error)
GetTrashCategoryByID(id string) (*model.TrashCategory, error) AddOrUpdateCartItem(ctx context.Context, cartID, trashCategoryID string, amount float64, estimatedPrice float64) error
GetCartByUserID(userID string) (*model.Cart, error) DeleteCartItem(ctx context.Context, cartID, trashCategoryID string) error
DeleteCartByUserID(userID string) error GetCartByUser(ctx context.Context, userID string) (*model.Cart, error)
UpdateCartTotals(ctx context.Context, cartID string) error
DeleteCart(ctx context.Context, userID string) error
// New method for batch cart creation
CreateCartWithItems(ctx context.Context, cart *model.Cart) error
// Check if user already has a cart
HasExistingCart(ctx context.Context, userID string) (bool, error)
} }
type cartRepository struct{} type cartRepository struct{}
@ -18,29 +30,137 @@ func NewCartRepository() CartRepository {
return &cartRepository{} return &cartRepository{}
} }
func (r *cartRepository) CreateCart(cart *model.Cart) error { func (r *cartRepository) FindOrCreateCart(ctx context.Context, userID string) (*model.Cart, error) {
return config.DB.Create(cart).Error
}
func (r *cartRepository) DeleteCartByUserID(userID string) error {
return config.DB.Where("user_id = ?", userID).Delete(&model.Cart{}).Error
}
func (r *cartRepository) GetTrashCategoryByID(id string) (*model.TrashCategory, error) {
var trash model.TrashCategory
if err := config.DB.First(&trash, "id = ?", id).Error; err != nil {
return nil, err
}
return &trash, nil
}
func (r *cartRepository) GetCartByUserID(userID string) (*model.Cart, error) {
var cart model.Cart var cart model.Cart
err := config.DB.Preload("CartItems.TrashCategory"). db := config.DB.WithContext(ctx)
err := db.
Preload("CartItems.TrashCategory").
Where("user_id = ?", userID). Where("user_id = ?", userID).
First(&cart).Error First(&cart).Error
if err == nil {
return &cart, nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
newCart := model.Cart{
UserID: userID,
TotalAmount: 0,
EstimatedTotalPrice: 0,
}
if err := db.Create(&newCart).Error; err != nil {
return nil, err
}
return &newCart, nil
}
return nil, err
}
func (r *cartRepository) AddOrUpdateCartItem(ctx context.Context, cartID, trashCategoryID string, amount float64, estimatedPrice float64) error {
db := config.DB.WithContext(ctx)
var item model.CartItem
err := db.
Where("cart_id = ? AND trash_category_id = ?", cartID, trashCategoryID).
First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
newItem := model.CartItem{
CartID: cartID,
TrashCategoryID: trashCategoryID,
Amount: amount,
SubTotalEstimatedPrice: amount * estimatedPrice,
}
return db.Create(&newItem).Error
}
if err != nil {
return err
}
item.Amount = amount
item.SubTotalEstimatedPrice = amount * estimatedPrice
return db.Save(&item).Error
}
func (r *cartRepository) DeleteCartItem(ctx context.Context, cartID, trashCategoryID string) error {
db := config.DB.WithContext(ctx)
return db.Where("cart_id = ? AND trash_category_id = ?", cartID, trashCategoryID).
Delete(&model.CartItem{}).Error
}
func (r *cartRepository) GetCartByUser(ctx context.Context, userID string) (*model.Cart, error) {
var cart model.Cart
db := config.DB.WithContext(ctx)
err := db.
Preload("CartItems.TrashCategory").
Where("user_id = ?", userID).
First(&cart).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &cart, nil return &cart, nil
} }
func (r *cartRepository) UpdateCartTotals(ctx context.Context, cartID string) error {
db := config.DB.WithContext(ctx)
var items []model.CartItem
if err := db.Where("cart_id = ?", cartID).Find(&items).Error; err != nil {
return err
}
var totalAmount float64
var totalPrice float64
for _, item := range items {
totalAmount += item.Amount
totalPrice += item.SubTotalEstimatedPrice
}
return db.Model(&model.Cart{}).
Where("id = ?", cartID).
Updates(map[string]interface{}{
"total_amount": totalAmount,
"estimated_total_price": totalPrice,
}).Error
}
func (r *cartRepository) DeleteCart(ctx context.Context, userID string) error {
db := config.DB.WithContext(ctx)
var cart model.Cart
if err := db.Where("user_id = ?", userID).First(&cart).Error; err != nil {
return err
}
return db.Delete(&cart).Error
}
// New method for batch cart creation with transaction
func (r *cartRepository) CreateCartWithItems(ctx context.Context, cart *model.Cart) error {
db := config.DB.WithContext(ctx)
// Use transaction to ensure data consistency
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(cart).Error; err != nil {
return fmt.Errorf("failed to create cart: %w", err)
}
return nil
})
}
// Check if user already has a cart
func (r *cartRepository) HasExistingCart(ctx context.Context, userID string) (bool, error) {
db := config.DB.WithContext(ctx)
var count int64
err := db.Model(&model.Cart{}).Where("user_id = ?", userID).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}

View File

@ -31,7 +31,7 @@ func NewAuthMasyarakatService(userRepo repositories.UserRepository, roleRepo rep
func (s *authMasyarakatService) generateJWTToken(userID string, deviceID string) (string, error) { func (s *authMasyarakatService) generateJWTToken(userID string, deviceID string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour) expirationTime := time.Now().Add(672 * time.Hour)
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"sub": userID, "sub": userID,

View File

@ -31,7 +31,7 @@ func NewAuthPengelolaService(userRepo repositories.UserRepository, roleRepo repo
func (s *authPengelolaService) generateJWTToken(userID string, deviceID string) (string, error) { func (s *authPengelolaService) generateJWTToken(userID string, deviceID string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour) expirationTime := time.Now().Add(168 * time.Hour)
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"sub": userID, "sub": userID,

View File

@ -31,7 +31,7 @@ func NewAuthPengepulService(userRepo repositories.UserRepository, roleRepo repos
func (s *authPengepulService) generateJWTToken(userID string, deviceID string) (string, error) { func (s *authPengepulService) generateJWTToken(userID string, deviceID string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour) expirationTime := time.Now().Add(480 * time.Hour)
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"sub": userID, "sub": userID,

View File

@ -8,97 +8,66 @@ import (
"rijig/config" "rijig/config"
"rijig/dto" "rijig/dto"
"github.com/go-redis/redis/v8"
) )
var cartTTL = 30 * time.Minute const CartTTL = 30 * time.Minute
const CartKeyPrefix = "cart:"
func getCartKey(userID string) string { func buildCartKey(userID string) string {
return fmt.Sprintf("cart:user:%s", userID) return fmt.Sprintf("%s%s", CartKeyPrefix, userID)
} }
func SetCartToRedis(ctx context.Context, userID string, cart dto.RequestCartDTO) error { func SetCartToRedis(ctx context.Context, userID string, cart dto.RequestCartDTO) error {
key := getCartKey(userID)
data, err := json.Marshal(cart) data, err := json.Marshal(cart)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal cart: %w", err) return err
} }
err = config.RedisClient.Set(ctx, key, data, cartTTL).Err() return config.RedisClient.Set(ctx, buildCartKey(userID), data, CartTTL).Err()
if err != nil { }
return fmt.Errorf("failed to save cart to redis: %w", err)
}
return nil func RefreshCartTTL(ctx context.Context, userID string) error {
return config.RedisClient.Expire(ctx, buildCartKey(userID), CartTTL).Err()
} }
func GetCartFromRedis(ctx context.Context, userID string) (*dto.RequestCartDTO, error) { func GetCartFromRedis(ctx context.Context, userID string) (*dto.RequestCartDTO, error) {
key := getCartKey(userID) val, err := config.RedisClient.Get(ctx, buildCartKey(userID)).Result()
val, err := config.RedisClient.Get(ctx, key).Result() if err == redis.Nil {
if err != nil { return nil, nil
} else if err != nil {
return nil, err return nil, err
} }
var cart dto.RequestCartDTO var cart dto.RequestCartDTO
if err := json.Unmarshal([]byte(val), &cart); err != nil { if err := json.Unmarshal([]byte(val), &cart); err != nil {
return nil, fmt.Errorf("failed to unmarshal cart data: %w", err) return nil, err
} }
return &cart, nil return &cart, nil
} }
func DeleteCartFromRedis(ctx context.Context, userID string) error { func DeleteCartFromRedis(ctx context.Context, userID string) error {
key := getCartKey(userID) return config.RedisClient.Del(ctx, buildCartKey(userID)).Err()
return config.RedisClient.Del(ctx, key).Err()
} }
func GetCartTTL(ctx context.Context, userID string) (time.Duration, error) { func GetExpiringCartKeys(ctx context.Context, threshold time.Duration) ([]string, error) {
key := getCartKey(userID) keys, err := config.RedisClient.Keys(ctx, CartKeyPrefix+"*").Result()
return config.RedisClient.TTL(ctx, key).Result()
}
func UpdateOrAddCartItemToRedis(ctx context.Context, userID string, item dto.RequestCartItemDTO) error {
cart, err := GetCartFromRedis(ctx, userID)
if err != nil { if err != nil {
return nil, err
cart = &dto.RequestCartDTO{
CartItems: []dto.RequestCartItemDTO{item},
}
return SetCartToRedis(ctx, userID, *cart)
} }
updated := false var expiringKeys []string
for i, ci := range cart.CartItems { for _, key := range keys {
if ci.TrashID == item.TrashID { ttl, err := config.RedisClient.TTL(ctx, key).Result()
cart.CartItems[i].Amount = item.Amount if err != nil {
updated = true continue
break }
if ttl > 0 && ttl <= threshold {
expiringKeys = append(expiringKeys, key)
} }
} }
if !updated {
cart.CartItems = append(cart.CartItems, item)
}
return SetCartToRedis(ctx, userID, *cart) return expiringKeys, nil
}
func RemoveCartItemFromRedis(ctx context.Context, userID, trashID string) error {
cart, err := GetCartFromRedis(ctx, userID)
if err != nil {
return err
}
updatedItems := make([]dto.RequestCartItemDTO, 0)
for _, ci := range cart.CartItems {
if ci.TrashID != trashID {
updatedItems = append(updatedItems, ci)
}
}
if len(updatedItems) == 0 {
return DeleteCartFromRedis(ctx, userID)
}
cart.CartItems = updatedItems
return SetCartToRedis(ctx, userID, *cart)
} }

View File

@ -1,35 +1,266 @@
package services package services
import ( import (
"rijig/dto"
"context" "context"
"errors"
"log"
"rijig/dto"
"rijig/internal/repositories"
"rijig/model"
) )
type CartService interface { type CartService interface {
GetCart(ctx context.Context, userID string) (*dto.RequestCartDTO, error) AddOrUpdateItem(ctx context.Context, userID string, req dto.RequestCartItemDTO) error
AddOrUpdateItem(ctx context.Context, userID string, item dto.RequestCartItemDTO) error GetCart(ctx context.Context, userID string) (*dto.ResponseCartDTO, error)
DeleteItem(ctx context.Context, userID string, trashID string) error DeleteItem(ctx context.Context, userID string, trashID string) error
ClearCart(ctx context.Context, userID string) error ClearCart(ctx context.Context, userID string) error
Checkout(ctx context.Context, userID string) error
} }
type cartService struct{} type cartService struct {
repo repositories.CartRepository
func NewCartService() CartService { trashRepo repositories.TrashRepository
return &cartService{}
} }
func (s *cartService) GetCart(ctx context.Context, userID string) (*dto.RequestCartDTO, error) { func NewCartService(repo repositories.CartRepository, trashRepo repositories.TrashRepository) CartService {
return GetCartFromRedis(ctx, userID) return &cartService{repo: repo, trashRepo: trashRepo}
} }
func (s *cartService) AddOrUpdateItem(ctx context.Context, userID string, item dto.RequestCartItemDTO) error { func (s *cartService) AddOrUpdateItem(ctx context.Context, userID string, req dto.RequestCartItemDTO) error {
return UpdateOrAddCartItemToRedis(ctx, userID, item) if req.Amount <= 0 {
return errors.New("amount harus lebih dari 0")
}
_, err := s.trashRepo.GetTrashCategoryByID(ctx, req.TrashID)
if err != nil {
return err
}
existingCart, err := GetCartFromRedis(ctx, userID)
if err != nil {
return err
}
if existingCart == nil {
existingCart = &dto.RequestCartDTO{
CartItems: []dto.RequestCartItemDTO{},
}
}
updated := false
for i, item := range existingCart.CartItems {
if item.TrashID == req.TrashID {
existingCart.CartItems[i].Amount = req.Amount
updated = true
break
}
}
if !updated {
existingCart.CartItems = append(existingCart.CartItems, dto.RequestCartItemDTO{
TrashID: req.TrashID,
Amount: req.Amount,
})
}
return SetCartToRedis(ctx, userID, *existingCart)
}
func (s *cartService) GetCart(ctx context.Context, userID string) (*dto.ResponseCartDTO, error) {
cached, err := GetCartFromRedis(ctx, userID)
if err != nil {
return nil, err
}
if cached != nil {
if err := RefreshCartTTL(ctx, userID); err != nil {
log.Printf("Warning: Failed to refresh cart TTL for user %s: %v", userID, err)
}
return s.buildResponseFromCache(ctx, userID, cached)
}
cart, err := s.repo.GetCartByUser(ctx, userID)
if err != nil {
return &dto.ResponseCartDTO{
ID: "",
UserID: userID,
TotalAmount: 0,
EstimatedTotalPrice: 0,
CartItems: []dto.ResponseCartItemDTO{},
}, nil
}
response := s.buildResponseFromDB(cart)
cacheData := dto.RequestCartDTO{CartItems: []dto.RequestCartItemDTO{}}
for _, item := range cart.CartItems {
cacheData.CartItems = append(cacheData.CartItems, dto.RequestCartItemDTO{
TrashID: item.TrashCategoryID,
Amount: item.Amount,
})
}
if err := SetCartToRedis(ctx, userID, cacheData); err != nil {
log.Printf("Warning: Failed to cache cart for user %s: %v", userID, err)
}
return response, nil
} }
func (s *cartService) DeleteItem(ctx context.Context, userID string, trashID string) error { func (s *cartService) DeleteItem(ctx context.Context, userID string, trashID string) error {
return RemoveCartItemFromRedis(ctx, userID, trashID)
existingCart, err := GetCartFromRedis(ctx, userID)
if err != nil {
return err
}
if existingCart == nil {
return errors.New("keranjang tidak ditemukan")
}
filtered := []dto.RequestCartItemDTO{}
for _, item := range existingCart.CartItems {
if item.TrashID != trashID {
filtered = append(filtered, item)
}
}
existingCart.CartItems = filtered
return SetCartToRedis(ctx, userID, *existingCart)
} }
func (s *cartService) ClearCart(ctx context.Context, userID string) error { func (s *cartService) ClearCart(ctx context.Context, userID string) error {
return DeleteCartFromRedis(ctx, userID)
if err := DeleteCartFromRedis(ctx, userID); err != nil {
return err
}
return s.repo.DeleteCart(ctx, userID)
}
func (s *cartService) Checkout(ctx context.Context, userID string) error {
cachedCart, err := GetCartFromRedis(ctx, userID)
if err != nil {
return err
}
if cachedCart != nil {
if err := s.commitCartFromRedis(ctx, userID, cachedCart); err != nil {
return err
}
}
_, err = s.repo.GetCartByUser(ctx, userID)
if err != nil {
return err
}
DeleteCartFromRedis(ctx, userID)
return s.repo.DeleteCart(ctx, userID)
}
func (s *cartService) buildResponseFromCache(ctx context.Context, userID string, cached *dto.RequestCartDTO) (*dto.ResponseCartDTO, error) {
totalQty := 0.0
totalPrice := 0.0
items := []dto.ResponseCartItemDTO{}
for _, item := range cached.CartItems {
trash, err := s.trashRepo.GetTrashCategoryByID(ctx, item.TrashID)
if err != nil {
log.Printf("Warning: Trash category %s not found for cached cart item", item.TrashID)
continue
}
subtotal := item.Amount * trash.EstimatedPrice
totalQty += item.Amount
totalPrice += subtotal
items = append(items, dto.ResponseCartItemDTO{
ID: "",
TrashID: item.TrashID,
TrashName: trash.Name,
TrashIcon: trash.Icon,
TrashPrice: trash.EstimatedPrice,
Amount: item.Amount,
SubTotalEstimatedPrice: subtotal,
})
}
return &dto.ResponseCartDTO{
ID: "-",
UserID: userID,
TotalAmount: totalQty,
EstimatedTotalPrice: totalPrice,
CartItems: items,
}, nil
}
func (s *cartService) buildResponseFromDB(cart *model.Cart) *dto.ResponseCartDTO {
var items []dto.ResponseCartItemDTO
for _, item := range cart.CartItems {
items = append(items, dto.ResponseCartItemDTO{
ID: item.ID,
TrashID: item.TrashCategoryID,
TrashName: item.TrashCategory.Name,
TrashIcon: item.TrashCategory.Icon,
TrashPrice: item.TrashCategory.EstimatedPrice,
Amount: item.Amount,
SubTotalEstimatedPrice: item.SubTotalEstimatedPrice,
})
}
return &dto.ResponseCartDTO{
ID: cart.ID,
UserID: cart.UserID,
TotalAmount: cart.TotalAmount,
EstimatedTotalPrice: cart.EstimatedTotalPrice,
CartItems: items,
}
}
func (s *cartService) commitCartFromRedis(ctx context.Context, userID string, cachedCart *dto.RequestCartDTO) error {
if len(cachedCart.CartItems) == 0 {
return nil
}
totalAmount := 0.0
totalPrice := 0.0
var cartItems []model.CartItem
for _, item := range cachedCart.CartItems {
trash, err := s.trashRepo.GetTrashCategoryByID(ctx, item.TrashID)
if err != nil {
log.Printf("Warning: Skipping invalid trash category %s during commit", item.TrashID)
continue
}
subtotal := item.Amount * trash.EstimatedPrice
totalAmount += item.Amount
totalPrice += subtotal
cartItems = append(cartItems, model.CartItem{
TrashCategoryID: item.TrashID,
Amount: item.Amount,
SubTotalEstimatedPrice: subtotal,
})
}
if len(cartItems) == 0 {
return nil
}
newCart := &model.Cart{
UserID: userID,
TotalAmount: totalAmount,
EstimatedTotalPrice: totalPrice,
CartItems: cartItems,
}
return s.repo.CreateCartWithItems(ctx, newCart)
} }

View File

@ -67,8 +67,6 @@ func (s *requestPickupService) ConvertCartToRequestPickup(ctx context.Context, u
Notes: req.Notes, Notes: req.Notes,
StatusPickup: "waiting_collector", StatusPickup: "waiting_collector",
RequestItems: requestItems, RequestItems: requestItems,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
} }
if err := s.pickupRepo.CreateRequestPickup(ctx, &pickup); err != nil { if err := s.pickupRepo.CreateRequestPickup(ctx, &pickup); err != nil {

View File

@ -3,109 +3,156 @@ package worker
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "log"
"strings" "strings"
"time" "time"
"rijig/config" "rijig/config"
"rijig/dto" "rijig/dto"
"rijig/internal/repositories"
"rijig/internal/services"
"rijig/model" "rijig/model"
) )
func CommitExpiredCartsToDB() error { type CartWorker struct {
cartService services.CartService
cartRepo repositories.CartRepository
trashRepo repositories.TrashRepository
}
func NewCartWorker(cartService services.CartService, cartRepo repositories.CartRepository, trashRepo repositories.TrashRepository) *CartWorker {
return &CartWorker{
cartService: cartService,
cartRepo: cartRepo,
trashRepo: trashRepo,
}
}
func (w *CartWorker) AutoCommitExpiringCarts() error {
ctx := context.Background() ctx := context.Background()
threshold := 1 * time.Minute
keys, err := config.RedisClient.Keys(ctx, "cart:user:*").Result() keys, err := services.GetExpiringCartKeys(ctx, threshold)
if err != nil { if err != nil {
return fmt.Errorf("error fetching cart keys: %w", err) return err
} }
if len(keys) == 0 {
return nil
}
log.Printf("[CART-WORKER] Found %d carts expiring within 1 minute", len(keys))
successCount := 0
for _, key := range keys { for _, key := range keys {
ttl, err := config.RedisClient.TTL(ctx, key).Result() userID := w.extractUserIDFromKey(key)
if err != nil || ttl > 30*time.Second { if userID == "" {
log.Printf("[CART-WORKER] Invalid key format: %s", key)
continue continue
} }
val, err := config.RedisClient.Get(ctx, key).Result() hasCart, err := w.cartRepo.HasExistingCart(ctx, userID)
if err != nil { if err != nil {
log.Printf("[CART-WORKER] Error checking existing cart for user %s: %v", userID, err)
continue continue
} }
var cart dto.RequestCartDTO if hasCart {
if err := json.Unmarshal([]byte(val), &cart); err != nil {
if err := services.DeleteCartFromRedis(ctx, userID); err != nil {
log.Printf("[CART-WORKER] Failed to delete Redis cache for user %s: %v", userID, err)
} else {
log.Printf("[CART-WORKER] Deleted Redis cache for user %s (already has DB cart)", userID)
}
continue continue
} }
userID := extractUserIDFromKey(key) cartData, err := w.getCartFromRedis(ctx, key)
if err != nil {
log.Printf("[CART-WORKER] Failed to get cart data for key %s: %v", key, err)
continue
}
cartID := SaveCartToDB(ctx, userID, &cart) if err := w.commitCartToDB(ctx, userID, cartData); err != nil {
log.Printf("[CART-WORKER] Failed to commit cart for user %s: %v", userID, err)
continue
}
_ = config.RedisClient.Del(ctx, key).Err() if err := services.DeleteCartFromRedis(ctx, userID); err != nil {
log.Printf("[CART-WORKER] Warning: Failed to delete Redis key after commit for user %s: %v", userID, err)
fmt.Printf( }
"[AUTO-COMMIT] UserID: %s | CartID: %s | TotalItem: %d | EstimatedTotalPrice: %.2f | Committed at: %s\n",
userID, cartID, len(cart.CartItems), calculateTotalEstimated(&cart), time.Now().Format(time.RFC3339),
)
successCount++
log.Printf("[CART-WORKER] Successfully auto-committed cart for user %s", userID)
} }
log.Printf("[CART-WORKER] Auto-commit completed: %d successful commits", successCount)
return nil return nil
} }
func extractUserIDFromKey(key string) string { func (w *CartWorker) extractUserIDFromKey(key string) string {
parts := strings.Split(key, ":") parts := strings.Split(key, ":")
if len(parts) == 3 { if len(parts) >= 2 {
return parts[2] return parts[len(parts)-1]
} }
return "" return ""
} }
func SaveCartToDB(ctx context.Context, userID string, cart *dto.RequestCartDTO) string { func (w *CartWorker) getCartFromRedis(ctx context.Context, key string) (*dto.RequestCartDTO, error) {
totalAmount := float32(0) val, err := config.RedisClient.Get(ctx, key).Result()
totalPrice := float32(0) if err != nil {
return nil, err
}
var cart dto.RequestCartDTO
if err := json.Unmarshal([]byte(val), &cart); err != nil {
return nil, err
}
return &cart, nil
}
func (w *CartWorker) commitCartToDB(ctx context.Context, userID string, cartData *dto.RequestCartDTO) error {
if len(cartData.CartItems) == 0 {
return nil
}
totalAmount := 0.0
totalPrice := 0.0
var cartItems []model.CartItem var cartItems []model.CartItem
for _, item := range cart.CartItems {
var trash model.TrashCategory for _, item := range cartData.CartItems {
if err := config.DB.First(&trash, "id = ?", item.TrashID).Error; err != nil { if item.Amount <= 0 {
continue continue
} }
subtotal := trash.EstimatedPrice * float64(item.Amount) trash, err := w.trashRepo.GetTrashCategoryByID(ctx, item.TrashID)
if err != nil {
log.Printf("[CART-WORKER] Warning: Skipping invalid trash category %s", item.TrashID)
continue
}
subtotal := item.Amount * trash.EstimatedPrice
totalAmount += item.Amount totalAmount += item.Amount
totalPrice += float32(subtotal) totalPrice += subtotal
cartItems = append(cartItems, model.CartItem{ cartItems = append(cartItems, model.CartItem{
TrashCategoryID: item.TrashID, TrashCategoryID: item.TrashID,
Amount: item.Amount, Amount: item.Amount,
SubTotalEstimatedPrice: float32(subtotal), SubTotalEstimatedPrice: subtotal,
}) })
} }
newCart := model.Cart{ if len(cartItems) == 0 {
return nil
}
newCart := &model.Cart{
UserID: userID, UserID: userID,
TotalAmount: totalAmount, TotalAmount: totalAmount,
EstimatedTotalPrice: totalPrice, EstimatedTotalPrice: totalPrice,
CartItems: cartItems, CartItems: cartItems,
} }
if err := config.DB.WithContext(ctx).Create(&newCart).Error; err != nil { return w.cartRepo.CreateCartWithItems(ctx, newCart)
fmt.Printf("Error committing cart: %v\n", err)
}
return newCart.ID
}
func calculateTotalEstimated(cart *dto.RequestCartDTO) float32 {
var total float32
for _, item := range cart.CartItems {
var trash model.TrashCategory
if err := config.DB.First(&trash, "id = ?", item.TrashID).Error; err != nil {
continue
}
total += item.Amount * float32(trash.EstimatedPrice)
}
return total
} }

View File

@ -9,8 +9,8 @@ type Cart struct {
UserID string `gorm:"not null" json:"user_id"` UserID string `gorm:"not null" json:"user_id"`
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE;" json:"-"` User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE;" json:"-"`
CartItems []CartItem `gorm:"foreignKey:CartID;constraint:OnDelete:CASCADE;" json:"cart_items"` CartItems []CartItem `gorm:"foreignKey:CartID;constraint:OnDelete:CASCADE;" json:"cart_items"`
TotalAmount float32 `json:"total_amount"` TotalAmount float64 `json:"total_amount"`
EstimatedTotalPrice float32 `json:"estimated_total_price"` EstimatedTotalPrice float64 `json:"estimated_total_price"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
} }
@ -21,8 +21,8 @@ type CartItem struct {
Cart *Cart `gorm:"foreignKey:CartID;constraint:OnDelete:CASCADE;" json:"-"` Cart *Cart `gorm:"foreignKey:CartID;constraint:OnDelete:CASCADE;" json:"-"`
TrashCategoryID string `gorm:"not null" json:"trash_id"` TrashCategoryID string `gorm:"not null" json:"trash_id"`
TrashCategory TrashCategory `gorm:"foreignKey:TrashCategoryID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"trash_category"` TrashCategory TrashCategory `gorm:"foreignKey:TrashCategoryID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"trash_category"`
Amount float32 `json:"amount"` Amount float64 `json:"amount"`
SubTotalEstimatedPrice float32 `json:"subtotal_estimated_price"` SubTotalEstimatedPrice float64 `json:"subtotal_estimated_price"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
} }

View File

@ -1,7 +1,9 @@
package presentation package presentation
import ( import (
"rijig/config"
"rijig/internal/handler" "rijig/internal/handler"
"rijig/internal/repositories"
"rijig/internal/services" "rijig/internal/services"
"rijig/middleware" "rijig/middleware"
@ -9,16 +11,18 @@ import (
) )
func TrashCartRouter(api fiber.Router) { func TrashCartRouter(api fiber.Router) {
cartService := services.NewCartService() repo := repositories.NewCartRepository()
trashRepo := repositories.NewTrashRepository(config.DB)
cartService := services.NewCartService(repo, trashRepo)
cartHandler := handler.NewCartHandler(cartService) cartHandler := handler.NewCartHandler(cartService)
cart := api.Group("/cart") cart := api.Group("/cart")
cart.Use(middleware.AuthMiddleware) cart.Use(middleware.AuthMiddleware)
cart.Get("/", cartHandler.GetCart) cart.Get("/", cartHandler.GetCart)
cart.Post("/item", cartHandler.AddOrUpdateCartItem) cart.Post("/item", cartHandler.AddOrUpdateItem)
cart.Post("/items", cartHandler.AddMultipleCartItems) cart.Delete("/item/:trash_id", cartHandler.DeleteItem)
cart.Delete("/item/:trashID", cartHandler.DeleteCartItem) cart.Delete("/clear", cartHandler.ClearCart)
cart.Delete("/", cartHandler.ClearCart)
} }
// cart.Post("/items", cartHandler.AddMultipleCartItems)

View File

@ -11,11 +11,13 @@ import (
) )
func CollectorRouter(api fiber.Router) { func CollectorRouter(api fiber.Router) {
cartRepo := repositories.NewCartRepository()
// trashRepo repositories.TrashRepository
pickupRepo := repositories.NewRequestPickupRepository() pickupRepo := repositories.NewRequestPickupRepository()
trashRepo := repositories.NewTrashRepository(config.DB) trashRepo := repositories.NewTrashRepository(config.DB)
historyRepo := repositories.NewPickupStatusHistoryRepository() historyRepo := repositories.NewPickupStatusHistoryRepository()
cartService := services.NewCartService() cartService := services.NewCartService(cartRepo, trashRepo)
pickupService := services.NewRequestPickupService(trashRepo, pickupRepo, cartService, historyRepo) pickupService := services.NewRequestPickupService(trashRepo, pickupRepo, cartService, historyRepo)
pickupHandler := handler.NewRequestPickupHandler(pickupService) pickupHandler := handler.NewRequestPickupHandler(pickupService)

View File

@ -11,10 +11,12 @@ import (
) )
func RequestPickupRouter(api fiber.Router) { func RequestPickupRouter(api fiber.Router) {
cartRepo := repositories.NewCartRepository()
pickupRepo := repositories.NewRequestPickupRepository() pickupRepo := repositories.NewRequestPickupRepository()
historyRepo := repositories.NewPickupStatusHistoryRepository() historyRepo := repositories.NewPickupStatusHistoryRepository()
trashRepo := repositories.NewTrashRepository(config.DB) trashRepo := repositories.NewTrashRepository(config.DB)
cartService := services.NewCartService()
cartService := services.NewCartService(cartRepo, trashRepo)
historyService := services.NewPickupStatusHistoryService(historyRepo) historyService := services.NewPickupStatusHistoryService(historyRepo)
pickupService := services.NewRequestPickupService(trashRepo, pickupRepo, cartService, historyRepo) pickupService := services.NewRequestPickupService(trashRepo, pickupRepo, cartService, historyRepo)

View File

@ -5,9 +5,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"time"
"rijig/config" "rijig/config"
"time"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
) )