From 328336ea5a023767b6b9207d30fff74ae8da6ee5 Mon Sep 17 00:00:00 2001 From: Efril Date: Wed, 3 Jun 2026 22:01:58 +0700 Subject: [PATCH 1/4] fix log error and omset tracker scheduled --- Makefile | 6 + internal/app/app.go | 5 +- internal/handler/common.go | 4 +- .../repository/organization_repository.go | 28 ++++ internal/repository/outlet_repository.go | 20 +++ internal/service/omset_milestone_scheduler.go | 125 +++++++++++------- internal/util/http_util.go | 6 + 7 files changed, 145 insertions(+), 49 deletions(-) diff --git a/Makefile b/Makefile index a24cc79..ebecae2 100644 --- a/Makefile +++ b/Makefile @@ -83,6 +83,12 @@ migration-up: migration-down: @migrate -database $(DB_URL) -path ./migrations down 1 +# Force migration to specific version + +.SILENT: migration-force +migration-force: + @migrate -database $(DB_URL) -path ./migrations force $(version) + .SILENT: seeder-create seeder-create: @migrate create -ext sql -dir ./seeders -seq $(name) diff --git a/internal/app/app.go b/internal/app/app.go index 3f57cd5..c29230e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -48,6 +48,7 @@ func (a *App) Initialize(cfg *config.Config) error { // Initialize omset milestone scheduler a.omsetScheduler = service.NewOmsetMilestoneScheduler( repos.organizationRepo, + repos.outletRepo, repos.userRepo, processors.notificationProcessor, ) @@ -141,9 +142,9 @@ func (a *App) Initialize(cfg *config.Config) error { } func (a *App) Start(port string) error { - // Start the omset milestone scheduler (checks every hour) + // Start the omset milestone scheduler (checks every 5 minutes for daily omset milestones) if a.omsetScheduler != nil { - a.omsetScheduler.Start(1 * time.Hour) + a.omsetScheduler.Start(5 * time.Minute) } engine := a.router.Init() diff --git a/internal/handler/common.go b/internal/handler/common.go index eb801a2..d8ece86 100644 --- a/internal/handler/common.go +++ b/internal/handler/common.go @@ -1,6 +1,8 @@ package handler import ( + "apskel-pos-be/internal/logger" + "fmt" "net/http" "time" ) @@ -47,7 +49,7 @@ func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { - + logger.FromContext(r.Context()).Error("Recovery", fmt.Sprintf("panic recovered: %v", err)) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } }() diff --git a/internal/repository/organization_repository.go b/internal/repository/organization_repository.go index b503324..bf668f7 100644 --- a/internal/repository/organization_repository.go +++ b/internal/repository/organization_repository.go @@ -2,6 +2,8 @@ package repository import ( "context" + "time" + "github.com/google/uuid" "apskel-pos-be/internal/entities" @@ -110,3 +112,29 @@ func (r *OrganizationRepositoryImpl) GetTotalOmset(ctx context.Context, organiza Scan(&total).Error return total, err } + +// GetTodayOmset returns the total revenue from completed orders for an organization on the current calendar day. +func (r *OrganizationRepositoryImpl) GetTodayOmset(ctx context.Context, organizationID uuid.UUID) (float64, error) { + var total float64 + err := r.db.WithContext(ctx). + Table("orders"). + Where( + "organization_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ? AND created_at >= ? AND created_at < ?", + organizationID, "completed", false, false, + todayStart(), tomorrowStart(), + ). + Select("COALESCE(SUM(total_amount), 0)"). + Scan(&total).Error + return total, err +} + +// todayStart returns midnight of the current local day. +func todayStart() time.Time { + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) +} + +// tomorrowStart returns midnight of the next local day. +func tomorrowStart() time.Time { + return todayStart().AddDate(0, 0, 1) +} diff --git a/internal/repository/outlet_repository.go b/internal/repository/outlet_repository.go index 4e46c81..80547a0 100644 --- a/internal/repository/outlet_repository.go +++ b/internal/repository/outlet_repository.go @@ -3,6 +3,7 @@ package repository import ( "apskel-pos-be/internal/entities" "context" + "time" "github.com/google/uuid" "gorm.io/gorm" @@ -103,3 +104,22 @@ func (r *OutletRepositoryImpl) Count(ctx context.Context, filters map[string]int err := query.Count(&count).Error return count, err } + +// GetTodayOmset returns the total revenue from completed orders for an outlet on the current calendar day. +func (r *OutletRepositoryImpl) GetTodayOmset(ctx context.Context, outletID uuid.UUID) (float64, error) { + var total float64 + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + tomorrowStart := todayStart.AddDate(0, 0, 1) + + err := r.db.WithContext(ctx). + Table("orders"). + Where( + "outlet_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ? AND created_at >= ? AND created_at < ?", + outletID, "completed", false, false, + todayStart, tomorrowStart, + ). + Select("COALESCE(SUM(total_amount), 0)"). + Scan(&total).Error + return total, err +} diff --git a/internal/service/omset_milestone_scheduler.go b/internal/service/omset_milestone_scheduler.go index 50266e3..c7866b5 100644 --- a/internal/service/omset_milestone_scheduler.go +++ b/internal/service/omset_milestone_scheduler.go @@ -4,11 +4,11 @@ import ( "context" "fmt" "log" + "math" "sync" "time" "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/processor" "apskel-pos-be/internal/repository" @@ -17,32 +17,38 @@ import ( ) const ( - defaultCheckInterval = 1 * time.Hour + defaultCheckInterval = 5 * time.Minute OmsetMillionRupiah = 1_000_000.0 ) -// OmsetMilestoneScheduler periodically checks each organization's total omset -// and sends a notification to owner/admin users when a milestone is reached. +// OmsetMilestoneScheduler periodically checks each outlet's omset for the +// current calendar day and sends a notification every time it crosses a new +// multiple of OmsetMillionRupiah (1 jt, 2 jt, 3 jt, …). // -// NOTE: Milestone tracking is in-memory; notifications may re-trigger after a restart. -// For persistent tracking, persist the notified state in the database. +// The notified state is keyed by "outletID:YYYY-MM-DD:N" so each multiple is +// only notified once per day. State resets naturally on the next day (new key). +// NOTE: state is in-memory; a server restart within the same day may re-send +// notifications for already-crossed milestones. type OmsetMilestoneScheduler struct { orgRepo *repository.OrganizationRepositoryImpl + outletRepo *repository.OutletRepositoryImpl userRepo *repository.UserRepositoryImpl notificationProc processor.NotificationProcessor mu sync.Mutex - notified map[string]bool // "orgID:milestone" -> already notified + notified map[string]bool // "outletID:YYYY-MM-DD:N" -> already notified stopCh chan struct{} } func NewOmsetMilestoneScheduler( orgRepo *repository.OrganizationRepositoryImpl, + outletRepo *repository.OutletRepositoryImpl, userRepo *repository.UserRepositoryImpl, notificationProc processor.NotificationProcessor, ) *OmsetMilestoneScheduler { return &OmsetMilestoneScheduler{ orgRepo: orgRepo, + outletRepo: outletRepo, userRepo: userRepo, notificationProc: notificationProc, notified: make(map[string]bool), @@ -57,8 +63,8 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) { } go func() { - // Perform an initial check immediately. - s.checkAllOrganizations() + // Perform an initial check immediately on startup. + s.checkAllOutlets() ticker := time.NewTicker(interval) defer ticker.Stop() @@ -66,7 +72,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) { for { select { case <-ticker.C: - s.checkAllOrganizations() + s.checkAllOutlets() case <-s.stopCh: log.Println("Omset milestone scheduler stopped") return @@ -74,7 +80,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) { } }() - log.Println("Omset milestone scheduler started") + log.Printf("Omset milestone scheduler started (interval: %s)", interval) } // Stop signals the scheduler to stop. @@ -82,7 +88,7 @@ func (s *OmsetMilestoneScheduler) Stop() { close(s.stopCh) } -func (s *OmsetMilestoneScheduler) checkAllOrganizations() { +func (s *OmsetMilestoneScheduler) checkAllOutlets() { ctx := context.Background() orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0) @@ -92,25 +98,38 @@ func (s *OmsetMilestoneScheduler) checkAllOrganizations() { } for _, org := range orgs { - s.checkOrganization(ctx, org) - } -} - -func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *entities.Organization) { - totalOmset, err := s.orgRepo.GetTotalOmset(ctx, org.ID) - if err != nil { - log.Printf("OmsetMilestoneScheduler: failed to get total omset for org %s: %v", org.ID, err) - return - } - - milestones := []float64{OmsetMillionRupiah} - - for _, milestone := range milestones { - if totalOmset < milestone { + outlets, err := s.outletRepo.GetByOrganizationID(ctx, org.ID) + if err != nil { + log.Printf("OmsetMilestoneScheduler: failed to list outlets for org %s: %v", org.ID, err) continue } - key := fmt.Sprintf("%s:%.0f", org.ID.String(), milestone) + for _, outlet := range outlets { + if !outlet.IsActive { + continue + } + s.checkOutlet(ctx, org.ID, outlet.ID, outlet.Name) + } + } +} + +func (s *OmsetMilestoneScheduler) checkOutlet(ctx context.Context, organizationID, outletID uuid.UUID, outletName string) { + todayOmset, err := s.outletRepo.GetTodayOmset(ctx, outletID) + if err != nil { + log.Printf("OmsetMilestoneScheduler: failed to get today's omset for outlet %s: %v", outletID, err) + return + } + + if todayOmset < OmsetMillionRupiah { + return + } + + // How many full multiples of 1 juta have been crossed today? + crossedMultiple := int(math.Floor(todayOmset / OmsetMillionRupiah)) + today := time.Now().Format("2006-01-02") + + for n := 1; n <= crossedMultiple; n++ { + key := fmt.Sprintf("%s:%s:%d", outletID.String(), today, n) s.mu.Lock() if s.notified[key] { @@ -120,23 +139,31 @@ func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *en s.notified[key] = true s.mu.Unlock() - s.sendMilestoneNotification(ctx, org, totalOmset, milestone) + milestone := float64(n) * OmsetMillionRupiah + s.sendMilestoneNotification(ctx, organizationID, outletID, outletName, todayOmset, milestone, n) } } -func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context, org *entities.Organization, totalOmset float64, milestone float64) { - users, err := s.userRepo.GetByOrganizationID(ctx, org.ID) +func (s *OmsetMilestoneScheduler) sendMilestoneNotification( + ctx context.Context, + organizationID, outletID uuid.UUID, + outletName string, + todayOmset, milestone float64, + multiple int, +) { + // Fetch all users in the org, then filter to owner and manager only. + // These roles are not assigned to a specific outlet, so we query by org. + users, err := s.userRepo.GetByOrganizationID(ctx, organizationID) if err != nil { - log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", org.ID, err) + log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", organizationID, err) return } - // Notify owner and admin users. var receiverIDs []uuid.UUID - for _, user := range users { - roleStr := string(user.Role) - if roleStr == string(constants.RoleOwner) || roleStr == string(constants.RoleAdmin) { - receiverIDs = append(receiverIDs, user.ID) + for _, u := range users { + role := string(u.Role) + if role == string(constants.RoleOwner) || role == string(constants.RoleManager) { + receiverIDs = append(receiverIDs, u.ID) } } @@ -144,28 +171,34 @@ func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context, return } - orgID := org.ID - title := "🎉 Selamat! Omset Telah Mencapai 1 Juta Rupiah" - body := fmt.Sprintf("Organisasi %s telah mencapai omset Rp %.0f. Terus tingkatkan prestasinya!", org.Name, totalOmset) + title := fmt.Sprintf("🎉 Omset %s Hari Ini Mencapai Rp %.0f!", outletName, milestone) + body := fmt.Sprintf( + "Selamat! Omset outlet %s hari ini sudah menembus Rp %.0f (total hari ini: Rp %.0f). Terus semangat!", + outletName, milestone, todayOmset, + ) notifReq := &models.SendNotificationRequest{ Title: title, Body: body, Type: "milestone", Category: "omset_milestone", - NotifiableType: "organization", - NotifiableID: &orgID, + NotifiableType: "outlet", + NotifiableID: &outletID, ReceiverIDs: receiverIDs, Data: map[string]interface{}{ - "organization_id": org.ID.String(), - "total_omset": totalOmset, + "organization_id": organizationID.String(), + "outlet_id": outletID.String(), + "outlet_name": outletName, + "today_omset": todayOmset, "milestone": milestone, + "multiple": multiple, }, } if _, err := s.notificationProc.Send(ctx, notifReq); err != nil { - log.Printf("OmsetMilestoneScheduler: failed to send notification for org %s: %v", org.ID, err) + log.Printf("OmsetMilestoneScheduler: failed to send notification for outlet %s: %v", outletID, err) } else { - log.Printf("OmsetMilestoneScheduler: sent milestone notification to org %s (omset: %.0f)", org.ID, totalOmset) + log.Printf("OmsetMilestoneScheduler: sent milestone x%d (Rp %.0f) for outlet %s (today omset: %.0f)", + multiple, milestone, outletName, todayOmset) } } diff --git a/internal/util/http_util.go b/internal/util/http_util.go index 11db7db..d799e08 100644 --- a/internal/util/http_util.go +++ b/internal/util/http_util.go @@ -16,6 +16,12 @@ func HandleResponse(w http.ResponseWriter, r *http.Request, response *contract.R } else { responseError := response.GetErrors()[0] statusCode = MapErrorCodeToHttpStatus(responseError.GetCode()) + logger.FromContext(r.Context()).WithFields(map[string]interface{}{ + "error_code": responseError.GetCode(), + "error_entity": responseError.GetEntity(), + "error_cause": responseError.GetCause(), + "status_code": statusCode, + }).Error(methodName) } WriteResponse(w, r, *response, statusCode, methodName) } From ea9dceb333d5f6b2db5767923c291d2fc6af54a0 Mon Sep 17 00:00:00 2001 From: Efril Date: Wed, 3 Jun 2026 23:59:15 +0700 Subject: [PATCH 2/4] fix: prevent race condition on order subtotal calculation --- internal/processor/order_processor.go | 14 ++++++++++++-- internal/repository/tx_manager.go | 13 +++++++++++++ internal/service/order_service.go | 22 +++++++++++++++++++--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 02e3d48..3d22958 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -338,7 +338,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, ProductID: itemReq.ProductID, ProductVariantID: itemReq.ProductVariantID, Quantity: itemReq.Quantity, - UnitPrice: unitPrice, // Use price from database + UnitPrice: unitPrice, TotalPrice: itemTotalPrice, UnitCost: unitCost, TotalCost: itemTotalCost, @@ -594,6 +594,10 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde return fmt.Errorf("order item does not belong to this order") } + if orderItem.Status == entities.OrderItemStatusCancelled { + return fmt.Errorf("order item %s is already cancelled", orderItemID) + } + if itemVoid.Quantity > orderItem.Quantity { return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID) } @@ -614,9 +618,15 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde return fmt.Errorf("outlet not found: %w", err) } + // Reload order to get latest state + order, err = p.orderRepo.GetByID(ctx, req.OrderID) + if err != nil { + return fmt.Errorf("failed to reload order: %w", err) + } + order.Subtotal -= totalVoidedAmount order.TotalCost -= totalVoidedCost - order.TaxAmount = order.Subtotal * outlet.TaxRate // Recalculate tax using outlet's tax rate + order.TaxAmount = order.Subtotal * outlet.TaxRate order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount if err := p.orderRepo.Update(ctx, order); err != nil { diff --git a/internal/repository/tx_manager.go b/internal/repository/tx_manager.go index 243a43a..ee9a3f8 100644 --- a/internal/repository/tx_manager.go +++ b/internal/repository/tx_manager.go @@ -2,6 +2,7 @@ package repository import ( "context" + "database/sql" "gorm.io/gorm" ) @@ -37,3 +38,15 @@ func (m *TxManager) WithTransaction(ctx context.Context, fn func(ctx context.Con return fn(ctxTx) }) } + +// WithTransactionOptions runs fn inside a DB transaction with custom TxOptions (e.g. isolation level). +func (m *TxManager) WithTransactionOptions(ctx context.Context, opts *sql.TxOptions, fn func(ctx context.Context) error) error { + if m == nil || m.db == nil { + return fn(ctx) + } + + return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + ctxTx := context.WithValue(ctx, txKey, tx) + return fn(ctxTx) + }, opts) +} diff --git a/internal/service/order_service.go b/internal/service/order_service.go index 84906c3..d7e450c 100644 --- a/internal/service/order_service.go +++ b/internal/service/order_service.go @@ -3,6 +3,7 @@ package service import ( "apskel-pos-be/internal/appcontext" "context" + "database/sql" "fmt" "time" @@ -228,7 +229,9 @@ func (s *OrderServiceImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, re var response *models.AddToOrderResponse var ingredientTransactions []*contract.CreateOrderIngredientTransactionRequest - err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{ + Isolation: sql.LevelSerializable, + }, func(txCtx context.Context) error { addResp, err := s.orderProcessor.AddToOrder(txCtx, orderID, req) if err != nil { return fmt.Errorf("failed to add items to order: %w", err) @@ -305,8 +308,16 @@ func (s *OrderServiceImpl) VoidOrder(ctx context.Context, req *models.VoidOrderR return fmt.Errorf("invalid user ID") } - if err := s.orderProcessor.VoidOrder(ctx, req, voidedBy); err != nil { - return fmt.Errorf("failed to void order: %w", err) + err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{ + Isolation: sql.LevelSerializable, + }, func(txCtx context.Context) error { + if err := s.orderProcessor.VoidOrder(txCtx, req, voidedBy); err != nil { + return fmt.Errorf("failed to void order: %w", err) + } + return nil + }) + if err != nil { + return err } if err := s.handleTableReleaseOnVoid(ctx, req.OrderID); err != nil { @@ -561,9 +572,14 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen return fmt.Errorf("payment item amount must be greater than zero for item %d", i+1) } + fmt.Printf("[DEBUG] CreatePayment order_id=%s item[%d] order_item_id=%s amount=%.10f\n", + req.OrderID, i, item.OrderItemID, item.Amount) totalItemAmount += item.Amount } + fmt.Printf("[DEBUG] CreatePayment order_id=%s total_amount=%.10f sum_items=%.10f diff=%.10f\n", + req.OrderID, req.Amount, totalItemAmount, req.Amount-totalItemAmount) + if totalItemAmount != req.Amount { return fmt.Errorf("sum of payment item amounts must equal total payment amount") } From 021ec152e99112ecb85eb30412018bbfa5ef3db6 Mon Sep 17 00:00:00 2001 From: Efril Date: Thu, 4 Jun 2026 00:49:45 +0700 Subject: [PATCH 3/4] feat: implement idempotency key for critical API endpoints --- internal/app/app.go | 1 + internal/middleware/idempotency.go | 137 +++++++++++++++++++++++++++++ internal/router/router.go | 15 ++-- 3 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 internal/middleware/idempotency.go diff --git a/internal/app/app.go b/internal/app/app.go index dc6c7d2..c8352a8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -138,6 +138,7 @@ func (a *App) Initialize(cfg *config.Config) error { selfOrderHandler, services.expenseService, validators.expenseValidator, + a.redisClient, ) return nil diff --git a/internal/middleware/idempotency.go b/internal/middleware/idempotency.go new file mode 100644 index 0000000..b3dc214 --- /dev/null +++ b/internal/middleware/idempotency.go @@ -0,0 +1,137 @@ +package middleware + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +const ( + IdempotencyKeyHeader = "X-Idempotency-Key" + idempotencyTTL = 24 * time.Hour + idempotencyPrefix = "idempotency:" +) + +type cachedResponse struct { + StatusCode int `json:"status_code"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` +} + +// IdempotencyMiddleware returns a Gin middleware that ensures idempotent processing +// for mutating operations. Client must send X-Idempotency-Key header. +func IdempotencyMiddleware(redisClient *redis.Client) gin.HandlerFunc { + return func(c *gin.Context) { + key := c.GetHeader(IdempotencyKeyHeader) + if key == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "success": false, + "errors": []gin.H{ + { + "code": "missing_idempotency_key", + "entity": "IdempotencyMiddleware", + "cause": "X-Idempotency-Key header is required", + }, + }, + }) + return + } + + redisKey := fmt.Sprintf("%s%s", idempotencyPrefix, key) + ctx := context.Background() + + fmt.Printf("[DEBUG] IdempotencyMiddleware: key=%s redisKey=%s\n", key, redisKey) + + // Check if key already exists (request was already processed) + cached, err := redisClient.Get(ctx, redisKey).Result() + if err == nil { + // Key exists — return cached response + fmt.Printf("[DEBUG] IdempotencyMiddleware: cache HIT for key=%s\n", key) + var resp cachedResponse + if err := json.Unmarshal([]byte(cached), &resp); err == nil { + for k, v := range resp.Headers { + c.Writer.Header().Set(k, v) + } + c.Writer.Header().Set("X-Idempotent-Replay", "true") + c.Data(resp.StatusCode, "application/json", []byte(resp.Body)) + c.Abort() + return + } + } else { + fmt.Printf("[DEBUG] IdempotencyMiddleware: cache MISS for key=%s err=%v\n", key, err) + } + + // Mark key as in-progress to prevent concurrent duplicates + set, err := redisClient.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result() + if err != nil { + // Redis error — proceed without idempotency (fail open) + c.Next() + return + } + if !set { + // Another request with the same key is being processed + c.AbortWithStatusJSON(http.StatusConflict, gin.H{ + "success": false, + "errors": []gin.H{ + { + "code": "request_in_progress", + "entity": "IdempotencyMiddleware", + "cause": "A request with this idempotency key is already being processed", + }, + }, + }) + return + } + + // Capture response using a custom writer + writer := &responseCapture{ + ResponseWriter: c.Writer, + body: &bytes.Buffer{}, + } + c.Writer = writer + + c.Next() + + // After handler completes, cache the response only if successful (2xx) + statusCode := writer.Status() + if statusCode >= 200 && statusCode < 300 { + resp := cachedResponse{ + StatusCode: statusCode, + Headers: map[string]string{ + "Content-Type": writer.Header().Get("Content-Type"), + }, + Body: writer.body.String(), + } + + respJSON, err := json.Marshal(resp) + if err == nil { + redisClient.Set(ctx, redisKey, string(respJSON), idempotencyTTL) + } + } else { + // Remove the in-progress key so the client can retry with the same key + redisClient.Del(ctx, redisKey) + } + } +} + +// responseCapture wraps gin.ResponseWriter to capture the response body +type responseCapture struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (w *responseCapture) Write(b []byte) (int, error) { + w.body.Write(b) + return w.ResponseWriter.Write(b) +} + +func (w *responseCapture) WriteString(s string) (int, error) { + w.body.WriteString(s) + return w.ResponseWriter.WriteString(s) +} diff --git a/internal/router/router.go b/internal/router/router.go index 4febd1b..3472b45 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -9,6 +9,7 @@ import ( "apskel-pos-be/internal/validator" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" ) type Router struct { @@ -53,9 +54,10 @@ type Router struct { expenseHandler *handler.ExpenseHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware + redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -99,6 +101,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer selfOrderHandler: selfOrderHandler, productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator), expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator), + redisClient: redisClient, } } @@ -274,19 +277,19 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { orders.GET("", r.orderHandler.ListOrders) orders.GET("/:id", r.orderHandler.GetOrderByID) orders.POST("", r.orderHandler.CreateOrder) - orders.POST("/:id/add-items", r.orderHandler.AddToOrder) + orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder) orders.PUT("/:id", r.orderHandler.UpdateOrder) orders.PUT("/:id/customer", r.orderHandler.SetOrderCustomer) - orders.POST("/void", r.orderHandler.VoidOrder) - orders.POST("/:id/refund", r.orderHandler.RefundOrder) + orders.POST("/void", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.VoidOrder) + orders.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundOrder) orders.POST("/split-bill", r.orderHandler.SplitBill) } payments := protected.Group("/payments") payments.Use(r.authMiddleware.RequireAdminOrManager()) { - payments.POST("", r.orderHandler.CreatePayment) - payments.POST("/:id/refund", r.orderHandler.RefundPayment) + payments.POST("", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.CreatePayment) + payments.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundPayment) } paymentMethods := protected.Group("/payment-methods") From c57620beeb610237e37946ef11fa43996f05a5c1 Mon Sep 17 00:00:00 2001 From: Efril Date: Mon, 8 Jun 2026 19:32:30 +0700 Subject: [PATCH 4/4] feat: update product analytic --- internal/contract/analytics_contract.go | 1 + internal/entities/analytics.go | 1 + internal/models/analytics.go | 1 + internal/processor/analytics_processor.go | 1 + internal/repository/analytics_repository.go | 3 ++- internal/transformer/analytics_transformer.go | 2 ++ 6 files changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 89c3327..f7d019a 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -162,6 +162,7 @@ type ProductAnalyticsData struct { ProductID uuid.UUID `json:"product_id"` ProductName string `json:"product_name"` ProductSku string `json:"product_sku"` + ProductPrice float64 `json:"product_price"` CategoryID uuid.UUID `json:"category_id"` CategoryName string `json:"category_name"` CategoryOrder int `json:"category_order"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 8cea304..0fa8bda 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -76,6 +76,7 @@ type ProductAnalytics struct { ProductID uuid.UUID `json:"product_id"` ProductName string `json:"product_name"` ProductSku string `json:"product_sku"` + ProductPrice float64 `json:"product_price"` CategoryID uuid.UUID `json:"category_id"` CategoryName string `json:"category_name"` CategoryOrder int `json:"category_order"` diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 4b94cba..ec8858d 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -172,6 +172,7 @@ type ProductAnalyticsData struct { ProductID uuid.UUID `json:"product_id"` ProductName string `json:"product_name"` ProductSku string `json:"product_sku"` + ProductPrice float64 `json:"product_price"` CategoryID uuid.UUID `json:"category_id"` CategoryName string `json:"category_name"` CategoryOrder int `json:"category_order"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index c8e41f6..d08a523 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -264,6 +264,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m ProductID: data.ProductID, ProductName: data.ProductName, ProductSku: data.ProductSku, + ProductPrice: data.ProductPrice, CategoryID: data.CategoryID, CategoryName: data.CategoryName, CategoryOrder: data.CategoryOrder, diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index b250dc9..62a9a90 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -284,6 +284,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ Select(` p.id as product_id, p.name as product_name, + p.price as product_price, c.id as category_id, c.name as category_name, c.order as category_order, @@ -342,7 +343,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ query = r.resolveOutletID(query, outletID, "o.outlet_id") err := query. - Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit"). + Group("p.id, p.name, p.price, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit"). Order("revenue DESC"). Limit(limit). Scan(&results).Error diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index f0538c4..f157598 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -257,6 +257,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con ProductID: item.ProductID, ProductName: item.ProductName, ProductSku: item.ProductSku, + ProductPrice: item.ProductPrice, CategoryID: item.CategoryID, CategoryName: item.CategoryName, CategoryOrder: item.CategoryOrder, @@ -367,6 +368,7 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse) topProducts = append(topProducts, contract.ProductAnalyticsData{ ProductID: item.ProductID, ProductName: item.ProductName, + ProductPrice: item.ProductPrice, CategoryID: item.CategoryID, CategoryName: item.CategoryName, QuantitySold: item.QuantitySold,