From 015292e8303764bc286f93f03a2a67be9bc4cc27 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 12 May 2026 21:47:35 +0700 Subject: [PATCH 1/3] Refactor: extract outlet ID filtering to helper method --- internal/repository/analytics_repository.go | 35 +++++++++------------ 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index a43970e..d25b487 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -29,6 +29,13 @@ func NewAnalyticsRepositoryImpl(db *gorm.DB) *AnalyticsRepositoryImpl { } } +func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid.UUID, column string) *gorm.DB { + if outletID != nil { + return query.Where(column+" = ?", *outletID) + } + return query +} + func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) { var results []*entities.PaymentMethodAnalytics @@ -50,9 +57,7 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, Where("p.status = ?", entities.PaymentTransactionStatusCompleted). Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) - if outletID != nil { - query = query.Where("o.outlet_id = ?", *outletID) - } + query = r.resolveOutletID(query, outletID, "o.outlet_id") err := query. Group("pm.id, pm.name, pm.type"). @@ -180,9 +185,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ Where("oi.status != ?", entities.OrderItemStatusCancelled). Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) - if outletID != nil { - query = query.Where("o.outlet_id = ?", *outletID) - } + 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"). @@ -235,9 +238,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con Where("oi.status != ?", entities.OrderItemStatusCancelled). Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) - if outletID != nil { - query = query.Where("o.outlet_id = ?", *outletID) - } + query = r.resolveOutletID(query, outletID, "o.outlet_id") err := query. Group("c.id, c.name"). @@ -267,9 +268,7 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga Where("o.organization_id = ?", organizationID). Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) - if outletID != nil { - query = query.Where("o.outlet_id = ?", *outletID) - } + query = r.resolveOutletID(query, outletID, "o.outlet_id") err := query.Scan(&result).Error if err != nil { @@ -320,9 +319,7 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or Where("o.is_void = false AND o.is_refund = false"). Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) - if outletID != nil { - summaryQuery = summaryQuery.Where("o.outlet_id = ?", *outletID) - } + summaryQuery = r.resolveOutletID(summaryQuery, outletID, "o.outlet_id") err := summaryQuery.Scan(&summary).Error if err != nil { @@ -374,9 +371,7 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or Group(timeFormat). Order(timeFormat) - if outletID != nil { - dataQuery = dataQuery.Where("o.outlet_id = ?", *outletID) - } + dataQuery = r.resolveOutletID(dataQuery, outletID, "o.outlet_id") err = dataQuery.Scan(&data).Error if err != nil { @@ -419,9 +414,7 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or Order("p.name ASC"). Limit(1000) - if outletID != nil { - productQuery = productQuery.Where("o.outlet_id = ?", *outletID) - } + productQuery = r.resolveOutletID(productQuery, outletID, "o.outlet_id") err = productQuery.Scan(&productData).Error if err != nil { From d38a770ec511ec949afda03f402032e8b1d3c2da Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 12 May 2026 23:00:27 +0700 Subject: [PATCH 2/3] Add omset milestone scheduler with owner role and revenue tracking --- internal/app/app.go | 27 ++- internal/constants/user.go | 2 + .../repository/organization_repository.go | 11 ++ internal/service/omset_milestone_scheduler.go | 171 ++++++++++++++++++ 4 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 internal/service/omset_milestone_scheduler.go diff --git a/internal/app/app.go b/internal/app/app.go index 3ba6f47..d680b42 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -25,11 +25,12 @@ import ( ) type App struct { - server *http.Server - db *gorm.DB - redisClient *redis.Client - router *router.Router - shutdown chan os.Signal + server *http.Server + db *gorm.DB + redisClient *redis.Client + router *router.Router + shutdown chan os.Signal + omsetScheduler *service.OmsetMilestoneScheduler } func NewApp(db *gorm.DB, redisClient *redis.Client) *App { @@ -43,6 +44,14 @@ func NewApp(db *gorm.DB, redisClient *redis.Client) *App { func (a *App) Initialize(cfg *config.Config) error { repos := a.initRepositories() processors := a.initProcessors(cfg, repos) + + // Initialize omset milestone scheduler + a.omsetScheduler = service.NewOmsetMilestoneScheduler( + repos.organizationRepo, + repos.userRepo, + processors.notificationProcessor, + ) + services := a.initServices(processors, repos, cfg) validators := a.initValidators() middleware := a.initMiddleware(services, cfg) @@ -129,6 +138,11 @@ func (a *App) Initialize(cfg *config.Config) error { } func (a *App) Start(port string) error { + // Start the omset milestone scheduler (checks every hour) + if a.omsetScheduler != nil { + a.omsetScheduler.Start(1 * time.Hour) + } + engine := a.router.Init() a.server = &http.Server{ @@ -164,6 +178,9 @@ func (a *App) Start(port string) error { } func (a *App) Shutdown() { + if a.omsetScheduler != nil { + a.omsetScheduler.Stop() + } close(a.shutdown) } diff --git a/internal/constants/user.go b/internal/constants/user.go index be7cd27..3a0542f 100644 --- a/internal/constants/user.go +++ b/internal/constants/user.go @@ -7,6 +7,7 @@ const ( RoleManager UserRole = "manager" RoleCashier UserRole = "cashier" RoleWaiter UserRole = "waiter" + RoleOwner UserRole = "owner" ) func GetAllUserRoles() []UserRole { @@ -15,6 +16,7 @@ func GetAllUserRoles() []UserRole { RoleManager, RoleCashier, RoleWaiter, + RoleOwner, } } diff --git a/internal/repository/organization_repository.go b/internal/repository/organization_repository.go index 8cbf1b7..49d365e 100644 --- a/internal/repository/organization_repository.go +++ b/internal/repository/organization_repository.go @@ -99,3 +99,14 @@ func (r *OrganizationRepositoryImpl) GetByEmail(ctx context.Context, email strin } return &org, nil } + +// GetTotalOmset returns the total revenue from completed orders for an organization. +func (r *OrganizationRepositoryImpl) GetTotalOmset(ctx context.Context, organizationID uuid.UUID) (float64, error) { + var total float64 + err := r.db.WithContext(ctx). + Table("orders"). + Where("organization_id = ? AND payment_status = ?", organizationID, "completed"). + 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 new file mode 100644 index 0000000..50266e3 --- /dev/null +++ b/internal/service/omset_milestone_scheduler.go @@ -0,0 +1,171 @@ +package service + +import ( + "context" + "fmt" + "log" + "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" + + "github.com/google/uuid" +) + +const ( + defaultCheckInterval = 1 * time.Hour + 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. +// +// NOTE: Milestone tracking is in-memory; notifications may re-trigger after a restart. +// For persistent tracking, persist the notified state in the database. +type OmsetMilestoneScheduler struct { + orgRepo *repository.OrganizationRepositoryImpl + userRepo *repository.UserRepositoryImpl + notificationProc processor.NotificationProcessor + + mu sync.Mutex + notified map[string]bool // "orgID:milestone" -> already notified + stopCh chan struct{} +} + +func NewOmsetMilestoneScheduler( + orgRepo *repository.OrganizationRepositoryImpl, + userRepo *repository.UserRepositoryImpl, + notificationProc processor.NotificationProcessor, +) *OmsetMilestoneScheduler { + return &OmsetMilestoneScheduler{ + orgRepo: orgRepo, + userRepo: userRepo, + notificationProc: notificationProc, + notified: make(map[string]bool), + stopCh: make(chan struct{}), + } +} + +// Start begins the periodic milestone check in a background goroutine. +func (s *OmsetMilestoneScheduler) Start(interval time.Duration) { + if interval <= 0 { + interval = defaultCheckInterval + } + + go func() { + // Perform an initial check immediately. + s.checkAllOrganizations() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + s.checkAllOrganizations() + case <-s.stopCh: + log.Println("Omset milestone scheduler stopped") + return + } + } + }() + + log.Println("Omset milestone scheduler started") +} + +// Stop signals the scheduler to stop. +func (s *OmsetMilestoneScheduler) Stop() { + close(s.stopCh) +} + +func (s *OmsetMilestoneScheduler) checkAllOrganizations() { + ctx := context.Background() + + orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0) + if err != nil { + log.Printf("OmsetMilestoneScheduler: failed to list organizations: %v", err) + return + } + + 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 { + continue + } + + key := fmt.Sprintf("%s:%.0f", org.ID.String(), milestone) + + s.mu.Lock() + if s.notified[key] { + s.mu.Unlock() + continue + } + s.notified[key] = true + s.mu.Unlock() + + s.sendMilestoneNotification(ctx, org, totalOmset, milestone) + } +} + +func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context, org *entities.Organization, totalOmset float64, milestone float64) { + users, err := s.userRepo.GetByOrganizationID(ctx, org.ID) + if err != nil { + log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", org.ID, 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) + } + } + + if len(receiverIDs) == 0 { + 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) + + notifReq := &models.SendNotificationRequest{ + Title: title, + Body: body, + Type: "milestone", + Category: "omset_milestone", + NotifiableType: "organization", + NotifiableID: &orgID, + ReceiverIDs: receiverIDs, + Data: map[string]interface{}{ + "organization_id": org.ID.String(), + "total_omset": totalOmset, + "milestone": milestone, + }, + } + + if _, err := s.notificationProc.Send(ctx, notifReq); err != nil { + log.Printf("OmsetMilestoneScheduler: failed to send notification for org %s: %v", org.ID, err) + } else { + log.Printf("OmsetMilestoneScheduler: sent milestone notification to org %s (omset: %.0f)", org.ID, totalOmset) + } +} From fa037b4d2aabe0966a7a9daded3abb5cc451ebe0 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 13 May 2026 14:23:27 +0700 Subject: [PATCH 3/3] fix request outlet id at analytic --- internal/contract/analytics_contract.go | 46 +++++++++---------- internal/handler/analytics_handler.go | 9 ++-- internal/transformer/analytics_transformer.go | 26 ++++++++--- 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 8e32a64..cc553a0 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -7,11 +7,11 @@ import ( ) type PaymentMethodAnalyticsRequest struct { - OrganizationID uuid.UUID `form:"organization_id"` - OutletID *uuid.UUID `form:"outlet_id,omitempty"` - DateFrom string `form:"date_from" validate:"required"` - DateTo string `form:"date_to" validate:"required"` - GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` + OrganizationID uuid.UUID `form:"organization_id"` + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` } // PaymentMethodAnalyticsResponse represents the response for payment method analytics @@ -45,10 +45,10 @@ type PaymentMethodAnalyticsData struct { type SalesAnalyticsRequest struct { OrganizationID uuid.UUID - OutletID *uuid.UUID `form:"outlet_id,omitempty"` - DateFrom string `form:"date_from" validate:"required"` - DateTo string `form:"date_to" validate:"required"` - GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` } type SalesAnalyticsResponse struct { @@ -86,10 +86,10 @@ type SalesAnalyticsData struct { // ProductAnalyticsRequest represents the request for product analytics type ProductAnalyticsRequest struct { OrganizationID uuid.UUID - OutletID *uuid.UUID `form:"outlet_id,omitempty"` - DateFrom string `form:"date_from" validate:"required"` - DateTo string `form:"date_to" validate:"required"` - Limit int `form:"limit,default=1000" validate:"min=1,max=1000"` + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + Limit int `form:"limit,default=1000" validate:"min=1,max=1000"` } // ProductAnalyticsResponse represents the response for product analytics @@ -123,9 +123,9 @@ type ProductAnalyticsData struct { // ProductAnalyticsPerCategoryRequest represents the request for product analytics per category type ProductAnalyticsPerCategoryRequest struct { OrganizationID uuid.UUID - OutletID *uuid.UUID `form:"outlet_id,omitempty"` - DateFrom string `form:"date_from" validate:"required"` - DateTo string `form:"date_to" validate:"required"` + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` } // ProductAnalyticsPerCategoryResponse represents the response for product analytics per category @@ -152,9 +152,9 @@ type ProductAnalyticsPerCategoryData struct { // DashboardAnalyticsRequest represents the request for dashboard analytics type DashboardAnalyticsRequest struct { OrganizationID uuid.UUID - OutletID *uuid.UUID `form:"outlet_id,omitempty"` - DateFrom string `form:"date_from" validate:"required"` - DateTo string `form:"date_to" validate:"required"` + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` } // DashboardAnalyticsResponse represents the response for dashboard analytics @@ -182,10 +182,10 @@ type DashboardOverview struct { // ProfitLossAnalyticsRequest represents the request for profit and loss analytics type ProfitLossAnalyticsRequest struct { OrganizationID uuid.UUID - OutletID *uuid.UUID `form:"outlet_id,omitempty"` - DateFrom string `form:"date_from" validate:"required"` - DateTo string `form:"date_to" validate:"required"` - GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` } // ProfitLossAnalyticsResponse represents the response for profit and loss analytics diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go index debe736..d40ae33 100644 --- a/internal/handler/analytics_handler.go +++ b/internal/handler/analytics_handler.go @@ -26,14 +26,13 @@ func NewAnalyticsHandler( } } -func (h *AnalyticsHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) *uuid.UUID { +func (h *AnalyticsHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) *string { if outletIDStr := c.Query("outlet_id"); outletIDStr != "" { - if parsedID, err := uuid.Parse(outletIDStr); err == nil { - return &parsedID - } + return &outletIDStr } if contextOutletID != uuid.Nil { - return &contextOutletID + s := contextOutletID.String() + return &s } return nil } diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index e776d1b..018dfdf 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -6,8 +6,22 @@ import ( "apskel-pos-be/internal/util" "fmt" "time" + + "github.com/google/uuid" ) +// parseOutletID converts a *string outlet ID to *uuid.UUID, returning nil for invalid/empty values. +func parseOutletID(s *string) *uuid.UUID { + if s == nil { + return nil + } + id, err := uuid.Parse(*s) + if err != nil { + return nil + } + return &id +} + // PaymentMethodAnalyticsContractToModel converts contract request to model func PaymentMethodAnalyticsContractToModel(req *contract.PaymentMethodAnalyticsRequest) *models.PaymentMethodAnalyticsRequest { var dateFrom, dateTo time.Time @@ -23,7 +37,7 @@ func PaymentMethodAnalyticsContractToModel(req *contract.PaymentMethodAnalyticsR return &models.PaymentMethodAnalyticsRequest{ OrganizationID: req.OrganizationID, - OutletID: req.OutletID, + OutletID: parseOutletID(req.OutletID), DateFrom: dateFrom, DateTo: dateTo, GroupBy: req.GroupBy, @@ -79,7 +93,7 @@ func SalesAnalyticsContractToModel(req *contract.SalesAnalyticsRequest) *models. return &models.SalesAnalyticsRequest{ OrganizationID: req.OrganizationID, - OutletID: req.OutletID, + OutletID: parseOutletID(req.OutletID), DateFrom: dateFrom, DateTo: dateTo, GroupBy: req.GroupBy, @@ -139,7 +153,7 @@ func ProductAnalyticsContractToModel(req *contract.ProductAnalyticsRequest) *mod return &models.ProductAnalyticsRequest{ OrganizationID: req.OrganizationID, - OutletID: req.OutletID, + OutletID: parseOutletID(req.OutletID), DateFrom: dateFrom, DateTo: dateTo, Limit: req.Limit, @@ -199,7 +213,7 @@ func ProductAnalyticsPerCategoryContractToModel(req *contract.ProductAnalyticsPe return &models.ProductAnalyticsPerCategoryRequest{ OrganizationID: req.OrganizationID, - OutletID: req.OutletID, + OutletID: parseOutletID(req.OutletID), DateFrom: dateFrom, DateTo: dateTo, } @@ -251,7 +265,7 @@ func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) return &models.DashboardAnalyticsRequest{ OrganizationID: req.OrganizationID, - OutletID: req.OutletID, + OutletID: parseOutletID(req.OutletID), DateFrom: dateFrom, DateTo: dateTo, } @@ -346,7 +360,7 @@ func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest return &models.ProfitLossAnalyticsRequest{ OrganizationID: req.OrganizationID, - OutletID: req.OutletID, + OutletID: parseOutletID(req.OutletID), DateFrom: *dateFrom, DateTo: *dateTo, GroupBy: req.GroupBy,