Files
agent-desk-ng/internal/services/support_calendar_service.go
mono 2f7a491666 feat: harden support, localize knowledge and add operator availability [skip ci]
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.
2026-10-16 15:21:42 +00:00

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)
}