package service import ( "context" "fmt" "log" "math" "sync" "time" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/models" "apskel-pos-be/internal/processor" "apskel-pos-be/internal/repository" "github.com/google/uuid" ) const ( defaultCheckInterval = 5 * time.Minute OmsetMillionRupiah = 1_000_000.0 ) // 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, …). // // 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 // "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), 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 on startup. s.checkAllOutlets() ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ticker.C: s.checkAllOutlets() case <-s.stopCh: log.Println("Omset milestone scheduler stopped") return } } }() log.Printf("Omset milestone scheduler started (interval: %s)", interval) } // Stop signals the scheduler to stop. func (s *OmsetMilestoneScheduler) Stop() { close(s.stopCh) } func (s *OmsetMilestoneScheduler) checkAllOutlets() { 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 { 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 } 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] { s.mu.Unlock() continue } s.notified[key] = true s.mu.Unlock() milestone := float64(n) * OmsetMillionRupiah s.sendMilestoneNotification(ctx, organizationID, outletID, outletName, todayOmset, milestone, n) } } 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", organizationID, err) return } var receiverIDs []uuid.UUID for _, u := range users { role := string(u.Role) if role == string(constants.RoleOwner) || role == string(constants.RoleManager) { receiverIDs = append(receiverIDs, u.ID) } } if len(receiverIDs) == 0 { return } 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: "outlet", NotifiableID: &outletID, ReceiverIDs: receiverIDs, Data: map[string]interface{}{ "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 outlet %s: %v", outletID, err) } else { log.Printf("OmsetMilestoneScheduler: sent milestone x%d (Rp %.0f) for outlet %s (today omset: %.0f)", multiple, milestone, outletName, todayOmset) } }