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) }