package service import ( "context" "fmt" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ) // WorkingHourService implements business logic for working hours / out-of-office detection. // Reference: Chatwoot app/models/concerns/out_of_offisable.rb type WorkingHourService struct { whRepo *repository.WorkingHourRepo inboxRepo *repository.InboxRepo accountRepo *repository.AccountRepo } func NewWorkingHourService(whRepo *repository.WorkingHourRepo, inboxRepo *repository.InboxRepo, accountRepo *repository.AccountRepo) *WorkingHourService { return &WorkingHourService{whRepo: whRepo, inboxRepo: inboxRepo, accountRepo: accountRepo} } // IsOutOfOffice checks whether an inbox is currently out of office. // Reference: Chatwoot out_of_offisable.rb — out_of_office? // - Returns true if: working_hours_enabled AND today's working hour says closed_now func (s *WorkingHourService) IsOutOfOffice(ctx context.Context, inboxID uint) (bool, error) { inbox, err := s.inboxRepo.FindByID(ctx, inboxID) if err != nil { return false, fmt.Errorf("inbox not found: %w", err) } // Chatwoot: "working_hours_enabled?" must be true to check out-of-office if !inbox.WorkingHoursEnabled { return false, nil // working hours not enabled → always "in office" } // Get today's day_of_week in the inbox's timezone // Chatwoot: Time.zone.now.in_time_zone(inbox.timezone).to_date.wday tz := time.UTC if inbox.Timezone != "" { loc, err := time.LoadLocation(inbox.Timezone) if err == nil { tz = loc } } now := time.Now().In(tz) dayOfWeek := int(now.Weekday()) // Sunday=0 in Go, matching Chatwoot // Find the working hour for today wh, err := s.whRepo.FindByInboxAndDay(ctx, inboxID, dayOfWeek) if err != nil { // No working hour record for this day → assume closed return true, nil } return s.isClosedNow(wh, now, tz), nil } // isClosedNow checks whether a specific WorkingHour indicates the inbox is closed right now. // Reference: Chatwoot working_hour.rb — closed_now? // - closed_all_day → always closed // - open_all_day → never closed // - otherwise: check if current time is outside [open_time, close_time) func (s *WorkingHourService) isClosedNow(wh *model.WorkingHour, now time.Time, tz *time.Location) bool { if wh.ClosedAllDay { return true } if wh.OpenAllDay { return false } if wh.OpenHour == nil || wh.CloseHour == nil { return true // missing data → assume closed } // Chatwoot: open_at?(time) and closed_now? logic // open_time = today at open_hour:open_minutes in inbox timezone // close_time = today at close_hour:close_minutes in inbox timezone openMinutes := *wh.OpenHour * 60 + *wh.OpenMinutes closeMinutes := *wh.CloseHour * 60 + *wh.CloseMinutes currentMinutes := now.Hour() * 60 + now.Minute() // closed_now: current time is NOT within open_time..close_time // Chatwoot: open_at?(time) → time >= open_time && time < close_time return currentMinutes < openMinutes || currentMinutes >= closeMinutes } // GetWeeklySchedule returns the 7-day working hours schedule for an inbox. // Reference: Chatwoot out_of_offisable.rb — weekly_schedule func (s *WorkingHourService) GetWeeklySchedule(ctx context.Context, inboxID uint) ([]WeeklyScheduleEntry, error) { hours, err := s.whRepo.FindByInbox(ctx, inboxID) if err != nil { return nil, err } entries := make([]WeeklyScheduleEntry, 0, len(hours)) for _, wh := range hours { entry := WeeklyScheduleEntry{ DayOfWeek: wh.DayOfWeek, ClosedAllDay: wh.ClosedAllDay, OpenAllDay: wh.OpenAllDay, OpenHour: wh.OpenHour, OpenMinutes: wh.OpenMinutes, CloseHour: wh.CloseHour, CloseMinutes: wh.CloseMinutes, } entries = append(entries, entry) } return entries, nil } // UpdateWeeklySchedule bulk-updates the working hours schedule. // Reference: Chatwoot out_of_offisable.rb — update_working_hours(params) // - Validates each entry before saving // - Runs updates in a transaction func (s *WorkingHourService) UpdateWeeklySchedule(ctx context.Context, inboxID uint, params []repository.WorkingHourUpdateParam) ([]WeeklyScheduleEntry, error) { // Validate each entry first for i, p := range params { temp := &model.WorkingHour{ DayOfWeek: p.DayOfWeek, ClosedAllDay: p.ClosedAllDay, OpenAllDay: p.OpenAllDay, OpenHour: p.OpenHour, OpenMinutes: p.OpenMinutes, CloseHour: p.CloseHour, CloseMinutes: p.CloseMinutes, } temp.EnsureOpenAllDayHours() if errs := temp.Validate(); len(errs) > 0 { return nil, fmt.Errorf("validation error for day %d (entry %d): %v", p.DayOfWeek, i, errs) } } if err := s.whRepo.UpdateWorkingHours(ctx, inboxID, params); err != nil { return nil, fmt.Errorf("update working hours: %w", err) } return s.GetWeeklySchedule(ctx, inboxID) } // InitDefaultWorkingHours creates the 7 default working hours when an inbox is created. // Reference: Chatwoot out_of_offisable.rb — after_create :create_default_working_hours func (s *WorkingHourService) InitDefaultWorkingHours(ctx context.Context, inboxID, accountID uint) error { return s.whRepo.CreateDefaultWorkingHours(ctx, inboxID, accountID) } // WeeklyScheduleEntry is the JSON response format for working hours. // Reference: Chatwoot out_of_offisable.rb — weekly_schedule type WeeklyScheduleEntry struct { DayOfWeek int `json:"day_of_week"` ClosedAllDay bool `json:"closed_all_day"` OpenAllDay bool `json:"open_all_day"` OpenHour *int `json:"open_hour"` OpenMinutes *int `json:"open_minutes"` CloseHour *int `json:"close_hour"` CloseMinutes *int `json:"close_minutes"` }