Files
warmbly/internal/scheduler/service.go
T

192 lines
6.2 KiB
Go

package scheduler
import (
"context"
"math/rand"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/behavior"
"github.com/warmbly/warmbly/internal/repository"
)
// SchedulerService provides task scheduling functionality
type SchedulerService interface {
// Warmup scheduling
CalculateNextWarmupTime(ctx context.Context, accountID uuid.UUID) (time.Time, error)
// Campaign scheduling
CalculateNextCampaignTime(ctx context.Context, campaignID uuid.UUID) (time.Time, *repository.ContactSequencePair, uuid.UUID, error)
// Email scheduling (smart send)
CalculateNextEmailTime(ctx context.Context, accountID uuid.UUID) (time.Time, error)
}
type schedulerService struct {
taskRepo repository.TaskRepository
warmupRepo repository.WarmupRepository
campaignProgressRepo repository.CampaignProgressRepository
emailRepo repository.EmailRepository
campaignRepo repository.CampaignRepository
// contactRepo is used only to read/cache the recipient ESP/provider for
// ESP matching (no MX dial on the hot path). nil-safe.
contactRepo repository.ContactRepository
// campaignLogRepo records send-path decision logs (e.g. ESP defer,
// new-lead cap). Optional/nil-safe so the scheduler keeps working without it.
campaignLogRepo repository.CampaignLogRepository
// behaviorSvc supplies each mailbox's rolled workday (hours, lunch, daily
// and hourly ceilings, send spacing). Optional/nil-safe: without it every
// mailbox keeps the legacy fixed cap and min-gap path.
behaviorSvc behavior.Service
// domainAuth resolves the operator's sending-domain authentication gate.
// Optional/nil-safe: without it the persisted auth state stays
// observe-only and never blocks a send.
domainAuth DomainAuthPolicy
}
// DomainAuthPolicy resolves whether the sending-domain authentication gate is
// enforced, and how long a domain must stay failing before it applies. Narrow
// and primitive-typed so the scheduler does not depend on the settings package;
// instancesettings.Service satisfies it.
type DomainAuthPolicy interface {
DomainAuth(ctx context.Context) (enforce bool, grace time.Duration)
}
// domainAuthGate resolves the gate for one scheduling pass. A nil policy, or an
// operator who turned enforcement off, means no mailbox is ever gated.
func (s *schedulerService) domainAuthGate(ctx context.Context) (bool, time.Duration) {
if s.domainAuth == nil {
return false, 0
}
return s.domainAuth.DomainAuth(ctx)
}
// WireBehavior attaches the sending-behaviour engine. Kept off the constructor
// so the scheduler stays constructible in tests and in any deployment that has
// not enabled the feature.
func (s *schedulerService) WireBehavior(b behavior.Service) {
s.behaviorSvc = b
}
// BehaviorAware is the optional capability the caller uses to attach the
// behaviour engine after construction.
type BehaviorAware interface {
WireBehavior(b behavior.Service)
}
// WireDomainAuth attaches the sending-domain authentication policy. Kept off
// the constructor for the same reason as WireBehavior: the scheduler must stay
// constructible in tests and in deployments that leave the gate off.
func (s *schedulerService) WireDomainAuth(p DomainAuthPolicy) {
s.domainAuth = p
}
// DomainAuthAware is the optional capability the caller uses to attach the
// authentication gate after construction.
type DomainAuthAware interface {
WireDomainAuth(p DomainAuthPolicy)
}
// NewSchedulerService creates a new scheduler service
func NewSchedulerService(
taskRepo repository.TaskRepository,
warmupRepo repository.WarmupRepository,
campaignProgressRepo repository.CampaignProgressRepository,
emailRepo repository.EmailRepository,
campaignRepo repository.CampaignRepository,
contactRepo repository.ContactRepository,
campaignLogRepo repository.CampaignLogRepository,
) SchedulerService {
return &schedulerService{
taskRepo: taskRepo,
warmupRepo: warmupRepo,
campaignProgressRepo: campaignProgressRepo,
emailRepo: emailRepo,
campaignRepo: campaignRepo,
contactRepo: contactRepo,
campaignLogRepo: campaignLogRepo,
}
}
// Timezone utilities
// loadLocation safely loads a timezone location with fallback to UTC
func loadLocation(tz string) *time.Location {
if tz == "" {
return time.UTC
}
loc, err := time.LoadLocation(tz)
if err != nil {
return time.UTC
}
return loc
}
// convertToAccountTimezone converts a time from campaign timezone to account timezone
func convertToAccountTimezone(t time.Time, campaignTZ, accountTZ string) time.Time {
campaignLoc := loadLocation(campaignTZ)
accountLoc := loadLocation(accountTZ)
// Get the wall clock time in campaign timezone
year, month, day := t.In(campaignLoc).Date()
hour, min, sec := t.In(campaignLoc).Clock()
// Reconstruct in account timezone
return time.Date(year, month, day, hour, min, sec, 0, accountLoc)
}
// Time calculation helpers
// parseTimeOfDay parses a time string like "08:00" and returns minutes since midnight
func parseTimeOfDay(timeStr string) int {
if timeStr == "" {
return 0
}
t, err := time.Parse("15:04", timeStr)
if err != nil {
return 0
}
return t.Hour()*60 + t.Minute()
}
// calculateBusinessHoursRemaining calculates hours remaining in business day
func calculateBusinessHoursRemaining(timezone string) float64 {
loc := loadLocation(timezone)
now := time.Now().In(loc)
// Business hours: 8am to 8pm (12 hours)
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 20, 0, 0, 0, loc)
if now.After(endOfDay) {
return 0
}
remaining := endOfDay.Sub(now).Hours()
return max(0, remaining)
}
// calculateFirstSlotTomorrow calculates first business hour slot tomorrow
func calculateFirstSlotTomorrow(timezone string) time.Time {
loc := loadLocation(timezone)
now := time.Now().In(loc)
// Tomorrow at 8am + random jitter (0-60 minutes)
tomorrow := now.Add(24 * time.Hour)
firstSlot := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 8, 0, 0, 0, loc)
// Add random jitter
jitter := randomJitter(0, 60)
return finalSlot(firstSlot.Add(time.Minute * time.Duration(jitter)))
}
// randomJitter generates random jitter between min and max minutes
func randomJitter(min, max int) int {
if min >= max {
return min
}
return min + rand.Intn(max-min)
}