package intervals import ( "errors" "time" "github.com/google/uuid" "github.com/robfig/cron/v3" "gorm.io/gorm" ) type Interval struct { ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` Interval IntervalType `json:"interval" gorm:"type:text;not null"` TimeOfDay *string `json:"timeOfDay" gorm:"type:text;"` // only for WEEKLY Weekday *int `json:"weekday,omitempty" gorm:"type:int"` // only for MONTHLY DayOfMonth *int `json:"dayOfMonth,omitempty" gorm:"type:int"` // only for CRON CronExpression *string `json:"cronExpression,omitempty" gorm:"type:text"` } func (i *Interval) BeforeSave(tx *gorm.DB) error { return i.Validate() } func (i *Interval) Validate() error { // for daily, weekly and monthly intervals time of day is required if (i.Interval == IntervalDaily || i.Interval == IntervalWeekly || i.Interval == IntervalMonthly) && i.TimeOfDay == nil { return errors.New("time of day is required for daily, weekly and monthly intervals") } // for weekly interval weekday is required if i.Interval == IntervalWeekly && i.Weekday == nil { return errors.New("weekday is required for weekly intervals") } // for monthly interval day of month is required if i.Interval == IntervalMonthly && i.DayOfMonth == nil { return errors.New("day of month is required for monthly intervals") } // for cron interval cron expression is required and must be valid if i.Interval == IntervalCron { if i.CronExpression == nil || *i.CronExpression == "" { return errors.New("cron expression is required for cron intervals") } if err := i.validateCronExpression(*i.CronExpression); err != nil { return err } } return nil } // ShouldTriggerBackup checks if a backup should be triggered based on the interval and last backup time func (i *Interval) ShouldTriggerBackup(now time.Time, lastBackupTime *time.Time) bool { // If no backup has been made yet, trigger immediately if lastBackupTime == nil { return true } switch i.Interval { case IntervalHourly: return now.Sub(*lastBackupTime) >= time.Hour case IntervalDaily: return i.shouldTriggerDaily(now, *lastBackupTime) case IntervalWeekly: return i.shouldTriggerWeekly(now, *lastBackupTime) case IntervalMonthly: return i.shouldTriggerMonthly(now, *lastBackupTime) case IntervalCron: return i.shouldTriggerCron(now, *lastBackupTime) default: return false } } // NextTriggerTime computes the next time a backup should trigger based on the interval and last backup time. // Returns nil when a backup is due immediately (no previous backup exists). func (i *Interval) NextTriggerTime(now time.Time, lastBackupTime *time.Time) *time.Time { if lastBackupTime == nil { return nil } switch i.Interval { case IntervalHourly: next := lastBackupTime.Add(time.Hour) return &next case IntervalDaily: next := i.nextDailyTrigger(now) return &next case IntervalWeekly: next := i.nextWeeklyTrigger(now) return &next case IntervalMonthly: next := i.nextMonthlyTrigger(now) return &next case IntervalCron: return i.nextCronTrigger(*lastBackupTime) default: return nil } } func (i *Interval) Copy() *Interval { return &Interval{ ID: uuid.Nil, Interval: i.Interval, TimeOfDay: i.TimeOfDay, Weekday: i.Weekday, DayOfMonth: i.DayOfMonth, CronExpression: i.CronExpression, } } // daily trigger: honour the TimeOfDay slot and catch up the previous one func (i *Interval) shouldTriggerDaily(now, lastBackup time.Time) bool { if i.TimeOfDay == nil { return !isSameDay(lastBackup, now) } t, err := time.Parse("15:04", *i.TimeOfDay) if err != nil { return false // malformed ⇒ play safe } // Today's scheduled slot (todayTgt) todayTgt := time.Date( now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, now.Location(), ) // The last scheduled slot that should already have happened var lastScheduled time.Time if now.Before(todayTgt) { lastScheduled = todayTgt.AddDate(0, 0, -1) } else { lastScheduled = todayTgt } // Fire when we are past that slot AND no backup has been taken since it return (now.After(lastScheduled) || now.Equal(lastScheduled)) && lastBackup.Before(lastScheduled) } // weekly trigger: on specified weekday/calendar week, otherwise ≥7 days func (i *Interval) shouldTriggerWeekly(now, lastBackup time.Time) bool { if i.Weekday != nil { targetWd := time.Weekday(*i.Weekday) // Calculate the target datetime for this week startOfWeek := getStartOfWeek(now) // Convert Go weekday to days from Monday: Sunday=6, Monday=0, Tuesday=1, ..., Saturday=5 var daysFromMonday int if targetWd == time.Sunday { daysFromMonday = 6 } else { daysFromMonday = int(targetWd) - 1 } targetThisWeek := startOfWeek.AddDate(0, 0, daysFromMonday) if i.TimeOfDay != nil { t, err := time.Parse("15:04", *i.TimeOfDay) if err == nil { targetThisWeek = time.Date( targetThisWeek.Year(), targetThisWeek.Month(), targetThisWeek.Day(), t.Hour(), t.Minute(), 0, 0, targetThisWeek.Location(), ) } } // If current time is at or after the target time this week // and no backup has been made at or after the target time, trigger if now.After(targetThisWeek) || now.Equal(targetThisWeek) { return lastBackup.Before(targetThisWeek) } return false } // no Weekday: generic 7-day interval return now.Sub(lastBackup) >= 7*24*time.Hour } // monthly trigger: on specified day/calendar month, otherwise next calendar month func (i *Interval) shouldTriggerMonthly(now, lastBackup time.Time) bool { if i.DayOfMonth != nil { day := *i.DayOfMonth // Calculate the target datetime for this month targetThisMonth := time.Date(now.Year(), now.Month(), day, 0, 0, 0, 0, now.Location()) if i.TimeOfDay != nil { t, err := time.Parse("15:04", *i.TimeOfDay) if err == nil { targetThisMonth = time.Date( targetThisMonth.Year(), targetThisMonth.Month(), targetThisMonth.Day(), t.Hour(), t.Minute(), 0, 0, targetThisMonth.Location(), ) } } // If current time is at or after the target time this month // and no backup has been made at or after the target time, trigger if now.After(targetThisMonth) || now.Equal(targetThisMonth) { return lastBackup.Before(targetThisMonth) } return false } // no DayOfMonth: if we're in a new calendar month return lastBackup.Before(getStartOfMonth(now)) } func isSameDay(a, b time.Time) bool { y1, m1, d1 := a.Date() y2, m2, d2 := b.Date() return y1 == y2 && m1 == m2 && d1 == d2 } func getStartOfWeek(t time.Time) time.Time { wd := int(t.Weekday()) if wd == 0 { wd = 7 } return time.Date(t.Year(), t.Month(), t.Day()-wd+1, 0, 0, 0, 0, t.Location()) } func getStartOfMonth(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) } // cron trigger: check if we've passed a scheduled cron time since last backup func (i *Interval) shouldTriggerCron(now, lastBackup time.Time) bool { if i.CronExpression == nil || *i.CronExpression == "" { return false } parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) schedule, err := parser.Parse(*i.CronExpression) if err != nil { return false } // Find the next scheduled time after the last backup nextAfterLastBackup := schedule.Next(lastBackup) // If we're at or past that next scheduled time, trigger return now.After(nextAfterLastBackup) || now.Equal(nextAfterLastBackup) } func (i *Interval) nextDailyTrigger(now time.Time) time.Time { t, err := time.Parse("15:04", *i.TimeOfDay) if err != nil { return now } todaySlot := time.Date( now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, now.Location(), ) if now.Before(todaySlot) { return todaySlot } return todaySlot.AddDate(0, 0, 1) } func (i *Interval) nextWeeklyTrigger(now time.Time) time.Time { targetWd := time.Weekday(0) if i.Weekday != nil { targetWd = time.Weekday(*i.Weekday) } startOfWeek := getStartOfWeek(now) var daysFromMonday int if targetWd == time.Sunday { daysFromMonday = 6 } else { daysFromMonday = int(targetWd) - 1 } targetThisWeek := startOfWeek.AddDate(0, 0, daysFromMonday) if i.TimeOfDay != nil { t, err := time.Parse("15:04", *i.TimeOfDay) if err == nil { targetThisWeek = time.Date( targetThisWeek.Year(), targetThisWeek.Month(), targetThisWeek.Day(), t.Hour(), t.Minute(), 0, 0, targetThisWeek.Location(), ) } } if now.Before(targetThisWeek) { return targetThisWeek } return targetThisWeek.AddDate(0, 0, 7) } func (i *Interval) nextMonthlyTrigger(now time.Time) time.Time { day := 1 if i.DayOfMonth != nil { day = *i.DayOfMonth } targetThisMonth := time.Date(now.Year(), now.Month(), day, 0, 0, 0, 0, now.Location()) if i.TimeOfDay != nil { t, err := time.Parse("15:04", *i.TimeOfDay) if err == nil { targetThisMonth = time.Date( targetThisMonth.Year(), targetThisMonth.Month(), targetThisMonth.Day(), t.Hour(), t.Minute(), 0, 0, targetThisMonth.Location(), ) } } if now.Before(targetThisMonth) { return targetThisMonth } return targetThisMonth.AddDate(0, 1, 0) } func (i *Interval) nextCronTrigger(lastBackup time.Time) *time.Time { if i.CronExpression == nil || *i.CronExpression == "" { return nil } parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) schedule, err := parser.Parse(*i.CronExpression) if err != nil { return nil } next := schedule.Next(lastBackup) return &next } func (i *Interval) validateCronExpression(expr string) error { parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) _, err := parser.Parse(expr) if err != nil { return errors.New("invalid cron expression: " + err.Error()) } return nil }