Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
338 lines
11 KiB
Go
338 lines
11 KiB
Go
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)
|
|
}
|