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.
This commit is contained in:
337
internal/services/support_calendar_service.go
Normal file
337
internal/services/support_calendar_service.go
Normal file
@@ -0,0 +1,337 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user