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