203 lines
5.6 KiB
Go
203 lines
5.6 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const defaultYearInReviewYear = 2025
|
|
|
|
// YearInReviewData mirrors Chatwoot's YearInReviewBuilder response shape.
|
|
type YearInReviewData struct {
|
|
Year int `json:"year"`
|
|
TotalConversations int64 `json:"total_conversations"`
|
|
BusiestDay *YearInReviewBusiestDay `json:"busiest_day"`
|
|
SupportPersonality YearInReviewSupportPerson `json:"support_personality"`
|
|
}
|
|
|
|
type YearInReviewBusiestDay struct {
|
|
Date string `json:"date"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
type YearInReviewSupportPerson struct {
|
|
AvgResponseTimeSeconds int64 `json:"avg_response_time_seconds"`
|
|
}
|
|
|
|
// YearInReviewService builds and caches the API v2 year-in-review payload.
|
|
type YearInReviewService struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewYearInReviewService(db *gorm.DB) *YearInReviewService {
|
|
return &YearInReviewService{db: db}
|
|
}
|
|
|
|
func DefaultYearInReviewYear() int { return defaultYearInReviewYear }
|
|
|
|
// Show returns cached Current.user.ui_settings data when present, otherwise builds
|
|
// the same aggregate payload as Chatwoot's YearInReviewBuilder and stores it.
|
|
func (s *YearInReviewService) Show(ctx context.Context, accountID, userID uint, year int) (*YearInReviewData, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, errors.New("year in review service unavailable")
|
|
}
|
|
if year == 0 {
|
|
year = defaultYearInReviewYear
|
|
}
|
|
|
|
var user model.User
|
|
if err := s.db.WithContext(ctx).Where("id = ? AND account_id = ?", userID, accountID).First(&user).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("year_in_review_%d_%d", accountID, year)
|
|
uiSettings, err := decodeUISettings(user.UISettings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cached, ok := uiSettings[cacheKey]; ok && cached != nil {
|
|
var data YearInReviewData
|
|
encoded, err := json.Marshal(cached)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(encoded, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
return &data, nil
|
|
}
|
|
|
|
data, err := s.Build(ctx, accountID, userID, year)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
uiSettings[cacheKey] = data
|
|
encoded, err := json.Marshal(uiSettings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ? AND account_id = ?", userID, accountID).Update("ui_settings", datatypes.JSON(encoded)).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (s *YearInReviewService) Build(ctx context.Context, accountID, userID uint, year int) (*YearInReviewData, error) {
|
|
loc, err := s.accountLocation(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
start, end := yearBoundsUTC(year, loc)
|
|
|
|
var conversations []model.Conversation
|
|
if err := s.db.WithContext(ctx).
|
|
Where("account_id = ? AND assignee_id = ? AND created_at >= ? AND created_at < ?", accountID, userID, start, end).
|
|
Find(&conversations).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
avg, err := s.averageFirstResponse(ctx, accountID, userID, start, end)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &YearInReviewData{
|
|
Year: year,
|
|
TotalConversations: int64(len(conversations)),
|
|
BusiestDay: busiestDay(conversations, loc),
|
|
SupportPersonality: YearInReviewSupportPerson{AvgResponseTimeSeconds: int64(avg)},
|
|
}, nil
|
|
}
|
|
|
|
func (s *YearInReviewService) accountLocation(ctx context.Context, accountID uint) (*time.Location, error) {
|
|
var account model.Account
|
|
if err := s.db.WithContext(ctx).Select("id", "timezone").Where("id = ?", accountID).First(&account).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if account.Timezone == "" {
|
|
return time.UTC, nil
|
|
}
|
|
loc, err := time.LoadLocation(account.Timezone)
|
|
if err != nil {
|
|
return time.UTC, nil
|
|
}
|
|
return loc, nil
|
|
}
|
|
|
|
func (s *YearInReviewService) averageFirstResponse(ctx context.Context, accountID, userID uint, start, end time.Time) (float64, error) {
|
|
var avg sql.NullFloat64
|
|
if err := s.db.WithContext(ctx).
|
|
Model(&model.ReportingEvent{}).
|
|
Where("account_id = ? AND name = ? AND user_id = ? AND created_at >= ? AND created_at < ?", accountID, model.MetricNameFirstResponse, userID, start, end).
|
|
Select("AVG(value)").
|
|
Scan(&avg).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
if !avg.Valid {
|
|
return 0, nil
|
|
}
|
|
return avg.Float64, nil
|
|
}
|
|
|
|
func yearBoundsUTC(year int, loc *time.Location) (time.Time, time.Time) {
|
|
if loc == nil {
|
|
loc = time.UTC
|
|
}
|
|
start := time.Date(year, time.January, 1, 0, 0, 0, 0, loc)
|
|
end := start.AddDate(1, 0, 0)
|
|
return start.UTC(), end.UTC()
|
|
}
|
|
|
|
func busiestDay(conversations []model.Conversation, loc *time.Location) *YearInReviewBusiestDay {
|
|
if len(conversations) == 0 {
|
|
return nil
|
|
}
|
|
counts := make(map[time.Time]int64)
|
|
for _, conversation := range conversations {
|
|
localDay := conversation.CreatedAt.In(loc)
|
|
bucket := time.Date(localDay.Year(), localDay.Month(), localDay.Day(), 0, 0, 0, 0, loc)
|
|
counts[bucket]++
|
|
}
|
|
days := make([]time.Time, 0, len(counts))
|
|
for day := range counts {
|
|
days = append(days, day)
|
|
}
|
|
sort.Slice(days, func(i, j int) bool { return days[i].Before(days[j]) })
|
|
|
|
var busiest time.Time
|
|
var max int64
|
|
for _, day := range days {
|
|
if counts[day] > max {
|
|
busiest = day
|
|
max = counts[day]
|
|
}
|
|
}
|
|
if max == 0 {
|
|
return nil
|
|
}
|
|
return &YearInReviewBusiestDay{Date: busiest.Format("Jan 02"), Count: max}
|
|
}
|
|
|
|
func decodeUISettings(raw datatypes.JSON) (map[string]any, error) {
|
|
settings := map[string]any{}
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return settings, nil
|
|
}
|
|
if err := json.Unmarshal(raw, &settings); err != nil {
|
|
return nil, err
|
|
}
|
|
if settings == nil {
|
|
settings = map[string]any{}
|
|
}
|
|
return settings, nil
|
|
}
|