package services import ( "agent-desk/internal/models" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/repositories" "encoding/json" "fmt" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "sort" "strings" "time" _ "time/tzdata" ) const supportNextOpeningSearchDays = 366 var SupportCalendarService = &supportCalendarService{} type supportCalendarService struct{} type supportCalendarEvaluation struct { Open bool Timezone, Mode string NextOpeningAt *time.Time } type supportCalendarInterval struct{ start, end time.Time } func supportAvailabilityPersistenceError(err error) error { if err == nil { return nil } return errorsx.InvalidParamI18n("error.supportAvailability.persistence") } func (s *supportCalendarService) Get(teamID int64) (*response.SupportCalendarResponse, error) { return s.get(sqls.DB(), teamID) } func (s *supportCalendarService) get(db *gorm.DB, teamID int64) (*response.SupportCalendarResponse, error) { global, err := repositories.SupportAvailabilityRepository.Calendar(db, 0) if err != nil { return nil, err } zone := "UTC" if global != nil && global.Timezone != "" { zone = global.Timezone } item := global if teamID != 0 { item, err = repositories.SupportAvailabilityRepository.Calendar(db, teamID) if err != nil { return nil, err } } ret := &response.SupportCalendarResponse{SupportCalendarRequest: request.SupportCalendarRequest{TeamID: teamID, Mode: "legacy", Weekly: []request.SupportCalendarWeeklyPeriod{}, Exceptions: []request.SupportCalendarException{}}, EffectiveTimezone: zone, DeploymentTimezone: zone} if teamID == 0 { ret.Timezone = zone ret.Mode = "recurring" } if item != nil { ret.Mode = item.Mode ret.Timezone = item.Timezone if err = json.Unmarshal([]byte(item.WeeklyJSON), &ret.Weekly); err != nil { return nil, err } if err = json.Unmarshal([]byte(item.ExceptionsJSON), &ret.Exceptions); err != nil { return nil, err } if item.Timezone != "" { ret.EffectiveTimezone = item.Timezone } } // Dated schedules are absolute stored instants; their existing input/display // convention is the process local zone, never the new recurring UTC default. if ret.Mode == "legacy" { ret.EffectiveTimezone = time.Local.String() } return ret, nil } func validateSupportCalendar(req request.SupportCalendarRequest) error { invalid := func() error { return errorsx.InvalidParamI18n("error.supportAvailability.invalidCalendar") } if req.TeamID < 0 || (req.Mode != "legacy" && req.Mode != "recurring") || len(req.Weekly) > 100 || len(req.Exceptions) > 730 { return invalid() } if req.TeamID == 0 && (req.Timezone == "" || len(req.Weekly) > 0 || len(req.Exceptions) > 0) { return invalid() } if req.Timezone != "" { if req.Timezone == "Local" { return invalid() } if _, err := time.LoadLocation(req.Timezone); err != nil { return invalid() } } for _, period := range req.Weekly { if period.Weekday < 1 || period.Weekday > 7 || !validSupportPeriod(period.SupportCalendarPeriod) { return invalid() } } seen := map[string]bool{} for _, exception := range req.Exceptions { date, err := time.Parse(time.DateOnly, exception.Date) if err != nil || date.Format(time.DateOnly) != exception.Date || seen[exception.Date] || len(exception.Periods) > 24 || exception.Closed && len(exception.Periods) > 0 { return invalid() } seen[exception.Date] = true for _, period := range exception.Periods { if !validSupportPeriod(period) { return invalid() } } } return nil } func supportClockMinutes(clock string, allow24 bool) (int, bool) { if allow24 && clock == "24:00" { return 1440, true } t, err := time.Parse("15:04", clock) if err != nil || t.Format("15:04") != clock { return 0, false } return t.Hour()*60 + t.Minute(), true } func validSupportPeriod(period request.SupportCalendarPeriod) bool { a, ok := supportClockMinutes(period.Start, false) b, ok2 := supportClockMinutes(period.End, true) return ok && ok2 && a != b } func (s *supportCalendarService) Save(req request.SupportCalendarRequest) (*response.SupportCalendarResponse, error) { req.Timezone = strings.TrimSpace(req.Timezone) if err := validateSupportCalendar(req); err != nil { return nil, err } if req.TeamID > 0 { team, err := repositories.SupportAvailabilityRepository.Team(sqls.DB(), req.TeamID) if err != nil { return nil, supportAvailabilityPersistenceError(err) } if team == nil { return nil, errorsx.InvalidParamI18n("error.e0169") } } if req.TeamID == 0 { req.Mode = "recurring" } if req.Weekly == nil { req.Weekly = []request.SupportCalendarWeeklyPeriod{} } if req.Exceptions == nil { req.Exceptions = []request.SupportCalendarException{} } weekly, _ := json.Marshal(req.Weekly) exceptions, _ := json.Marshal(req.Exceptions) item := &models.SupportCalendar{TeamID: req.TeamID, Mode: req.Mode, Timezone: req.Timezone, WeeklyJSON: string(weekly), ExceptionsJSON: string(exceptions), UpdatedAt: time.Now()} if err := repositories.SupportAvailabilityRepository.SaveCalendar(sqls.DB(), item); err != nil { return nil, supportAvailabilityPersistenceError(err) } _, _ = ConversationDispatchService.DispatchPendingConversations(0) ret, err := s.Get(req.TeamID) return ret, supportAvailabilityPersistenceError(err) } func (s *supportCalendarService) Preview(req request.SupportCalendarPreviewRequest) (*response.SupportCalendarPreviewResponse, error) { if err := validateSupportCalendar(req.Calendar); err != nil { return nil, err } now := time.Now() if req.At != "" { var err error now, err = time.Parse(time.RFC3339, req.At) if err != nil { return nil, errorsx.InvalidParamI18n("error.supportAvailability.invalidTime") } } calendar := &response.SupportCalendarResponse{SupportCalendarRequest: req.Calendar, EffectiveTimezone: req.Calendar.Timezone} if calendar.EffectiveTimezone == "" { global, err := s.get(sqls.DB(), 0) if err != nil { return nil, supportAvailabilityPersistenceError(err) } calendar.EffectiveTimezone = global.EffectiveTimezone } result, err := s.evaluateCalendar(sqls.DB(), calendar, now) if err != nil { return nil, supportAvailabilityPersistenceError(err) } return &response.SupportCalendarPreviewResponse{Open: result.Open, Timezone: result.Timezone, Mode: result.Mode, NextOpeningAt: supportTimeString(result.NextOpeningAt)}, nil } func (s *supportCalendarService) Evaluate(db *gorm.DB, teamID int64, now time.Time) (supportCalendarEvaluation, error) { calendar, err := s.get(db, teamID) if err != nil { return supportCalendarEvaluation{}, err } return s.evaluateCalendar(db, calendar, now) } func (s *supportCalendarService) evaluateCalendar(db *gorm.DB, calendar *response.SupportCalendarResponse, now time.Time) (supportCalendarEvaluation, error) { if calendar.Mode == "legacy" { ret := supportCalendarEvaluation{Mode: "legacy", Timezone: time.Local.String()} items, err := repositories.SupportAvailabilityRepository.LegacySchedules(db, calendar.TeamID, now) if err != nil { return ret, err } for _, item := range items { if !item.StartAt.After(now) { ret.Open = true } else if ret.NextOpeningAt == nil { next := item.StartAt ret.NextOpeningAt = &next } } if ret.Open { ret.NextOpeningAt = nil } return ret, nil } return evaluateRecurringSupportCalendar(calendar, now) } func evaluateRecurringSupportCalendar(calendar *response.SupportCalendarResponse, now time.Time) (supportCalendarEvaluation, error) { ret := supportCalendarEvaluation{Mode: "recurring", Timezone: calendar.EffectiveTimezone} loc, err := time.LoadLocation(calendar.EffectiveTimezone) if err != nil { return ret, err } day := now.In(loc) year, month, date := day.Date() day = time.Date(year, month, date, 12, 0, 0, 0, loc) exceptions := map[string]request.SupportCalendarException{} for _, item := range calendar.Exceptions { exceptions[item.Date] = item } for offset := -1; offset <= supportNextOpeningSearchDays; offset++ { current := day.AddDate(0, 0, offset) periods := []request.SupportCalendarPeriod{} if exception, ok := exceptions[current.Format(time.DateOnly)]; ok { if !exception.Closed { periods = exception.Periods } } else { for _, p := range calendar.Weekly { if p.Weekday == weekdayForBatchRequest(current) { periods = append(periods, p.SupportCalendarPeriod) } } } intervals := []supportCalendarInterval{} for _, period := range periods { startMinute, ok := supportClockMinutes(period.Start, false) endMinute, ok2 := supportClockMinutes(period.End, true) if !ok || !ok2 || startMinute == endMinute { return ret, fmt.Errorf("invalid calendar period") } endDay := current if endMinute <= startMinute || endMinute == 1440 { endDay = current.AddDate(0, 0, 1) if endMinute == 1440 { endMinute = 0 } } start, err := resolveSupportWallTime(current, startMinute, loc, false) if err != nil { return ret, err } end, err := resolveSupportWallTime(endDay, endMinute, loc, true) if err != nil { return ret, err } // Any exception owns its whole civil date, including an overnight carry. if endDay.Format(time.DateOnly) != current.Format(time.DateOnly) { if _, exists := exceptions[endDay.Format(time.DateOnly)]; exists { end, err = resolveSupportWallTime(endDay, 0, loc, false) if err != nil { return ret, err } } } if end.After(start) { intervals = append(intervals, supportCalendarInterval{start: start, end: end}) } } sort.Slice(intervals, func(i, j int) bool { return intervals[i].start.Before(intervals[j].start) }) for _, interval := range intervals { if !now.Before(interval.start) && now.Before(interval.end) { ret.Open = true ret.NextOpeningAt = nil return ret, nil } if interval.start.After(now) && (ret.NextOpeningAt == nil || interval.start.Before(*ret.NextOpeningAt)) { next := interval.start ret.NextOpeningAt = &next } } if ret.NextOpeningAt != nil && offset >= 0 { return ret, nil } } return ret, nil } // At a DST fold the earlier occurrence starts a shift and the later occurrence // ends it. Missing boundary minutes move forward to the first valid wall minute. // This is deterministic across hosts and includes both occurrences of a fold. func resolveSupportWallTime(day time.Time, minute int, loc *time.Location, end bool) (time.Time, error) { y, m, d := day.Date() wall := time.Date(y, m, d, minute/60, minute%60, 0, 0, time.UTC) offsets := map[int]bool{} for delta := -48; delta <= 48; delta += 6 { _, offset := day.Add(time.Duration(delta) * time.Hour).Zone() offsets[offset] = true } for shift := 0; shift <= 24*60; shift++ { target := wall.Add(time.Duration(shift) * time.Minute) var candidates []time.Time for offset := range offsets { instant := target.Add(-time.Duration(offset) * time.Second) local := instant.In(loc) if local.Format("2006-01-02 15:04") == target.Format("2006-01-02 15:04") { candidates = append(candidates, instant) } } if len(candidates) > 0 { sort.Slice(candidates, func(i, j int) bool { return candidates[i].Before(candidates[j]) }) if end { return candidates[len(candidates)-1], nil } return candidates[0], nil } } return time.Time{}, fmt.Errorf("unresolvable civil time") } func supportTimeString(value *time.Time) string { if value == nil { return "" } return value.UTC().Format(time.RFC3339) }