356 lines
8.8 KiB
Go
356 lines
8.8 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"tm/pkg/logger"
|
|
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
const (
|
|
defaultClosingWindowHours = 168
|
|
defaultTrendDays = 14
|
|
maxTrendDays = 90
|
|
defaultCountriesLimit = 6
|
|
defaultListLimit = 5
|
|
maxListLimit = 20
|
|
summaryCacheTTL = 60 * time.Second
|
|
statisticsCacheTTL = 60 * time.Second
|
|
)
|
|
|
|
// Service defines dashboard business operations.
|
|
type Service interface {
|
|
Summary(ctx context.Context, query SummaryQuery) (*SummaryResponse, error)
|
|
Trend(ctx context.Context, query TrendQuery) (*TrendResponse, error)
|
|
Countries(ctx context.Context, query CountriesQuery) (*CountriesResponse, error)
|
|
NoticeTypes(ctx context.Context) (*NoticeTypesResponse, error)
|
|
ClosingSoon(ctx context.Context, query ClosingSoonQuery) (*ClosingSoonResponse, error)
|
|
Recent(ctx context.Context, query RecentQuery) (*RecentResponse, error)
|
|
Statistics(ctx context.Context, query StatisticsQuery) (*StatisticsReportResponse, error)
|
|
}
|
|
|
|
type cacheEntry[T any] struct {
|
|
expiresAt time.Time
|
|
value T
|
|
}
|
|
|
|
type service struct {
|
|
repo Repository
|
|
logger logger.Logger
|
|
summaryMu sync.Mutex
|
|
summary map[int64]cacheEntry[*SummaryResponse]
|
|
summaryGroup singleflight.Group
|
|
statisticsMu sync.Mutex
|
|
statistics map[int]cacheEntry[*StatisticsReportResponse]
|
|
statisticsGroup singleflight.Group
|
|
}
|
|
|
|
// NewService creates a dashboard service.
|
|
func NewService(repo Repository, log logger.Logger) Service {
|
|
return &service{
|
|
repo: repo,
|
|
logger: log,
|
|
summary: make(map[int64]cacheEntry[*SummaryResponse]),
|
|
statistics: make(map[int]cacheEntry[*StatisticsReportResponse]),
|
|
}
|
|
}
|
|
|
|
func (s *service) Summary(ctx context.Context, query SummaryQuery) (*SummaryResponse, error) {
|
|
windowHours := closingWindowHours(query.ClosingWindow)
|
|
windowSec := int64(windowHours) * 3600
|
|
|
|
if cached, ok := s.cachedSummary(windowSec); ok {
|
|
return cached, nil
|
|
}
|
|
|
|
cacheKey := strconv.FormatInt(windowSec, 10)
|
|
out, err, _ := s.summaryGroup.Do(cacheKey, func() (interface{}, error) {
|
|
if cached, ok := s.cachedSummary(windowSec); ok {
|
|
return cached, nil
|
|
}
|
|
|
|
s.logger.Info("Fetching dashboard summary", map[string]interface{}{
|
|
"closing_window_hours": windowHours,
|
|
})
|
|
|
|
result, err := s.repo.Summary(context.WithoutCancel(ctx), windowSec)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch dashboard summary", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("dashboard summary: %w", err)
|
|
}
|
|
|
|
s.storeSummary(windowSec, result)
|
|
return result, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return out.(*SummaryResponse), nil
|
|
}
|
|
|
|
func (s *service) Trend(ctx context.Context, query TrendQuery) (*TrendResponse, error) {
|
|
days := trendDays(query.Days)
|
|
metric := normalizeTrendMetric(query.Metric)
|
|
startUnix := trendStartUnix(days)
|
|
|
|
s.logger.Info("Fetching dashboard trend", map[string]interface{}{
|
|
"days": days,
|
|
"metric": metric,
|
|
})
|
|
|
|
counts, err := s.repo.Trend(ctx, days, metric, startUnix)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch dashboard trend", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("dashboard trend: %w", err)
|
|
}
|
|
|
|
series := fillTrendSeries(days, counts)
|
|
|
|
return &TrendResponse{
|
|
Metric: metric,
|
|
Days: days,
|
|
Series: series,
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) Countries(ctx context.Context, query CountriesQuery) (*CountriesResponse, error) {
|
|
limit := countriesLimit(query.Limit)
|
|
|
|
s.logger.Info("Fetching dashboard countries", map[string]interface{}{
|
|
"limit": limit,
|
|
})
|
|
|
|
out, err := s.repo.Countries(ctx, limit)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch dashboard countries", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("dashboard countries: %w", err)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (s *service) NoticeTypes(ctx context.Context) (*NoticeTypesResponse, error) {
|
|
s.logger.Info("Fetching dashboard notice types", nil)
|
|
|
|
out, err := s.repo.NoticeTypes(ctx)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch dashboard notice types", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("dashboard notice types: %w", err)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (s *service) ClosingSoon(ctx context.Context, query ClosingSoonQuery) (*ClosingSoonResponse, error) {
|
|
limit := listLimit(query.Limit)
|
|
windowHours := closingWindowHours(query.Window)
|
|
windowSec := int64(windowHours) * 3600
|
|
|
|
s.logger.Info("Fetching dashboard closing soon", map[string]interface{}{
|
|
"limit": limit,
|
|
"window": windowHours,
|
|
})
|
|
|
|
items, err := s.repo.ClosingSoon(ctx, limit, windowSec)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch dashboard closing soon", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("dashboard closing soon: %w", err)
|
|
}
|
|
|
|
return &ClosingSoonResponse{Items: items}, nil
|
|
}
|
|
|
|
func (s *service) Recent(ctx context.Context, query RecentQuery) (*RecentResponse, error) {
|
|
limit := listLimit(query.Limit)
|
|
|
|
s.logger.Info("Fetching dashboard recent tenders", map[string]interface{}{
|
|
"limit": limit,
|
|
})
|
|
|
|
items, nextCursor, err := s.repo.Recent(ctx, limit, strings.TrimSpace(query.Cursor))
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch dashboard recent tenders", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("dashboard recent: %w", err)
|
|
}
|
|
|
|
return &RecentResponse{
|
|
Items: items,
|
|
NextCursor: nextCursor,
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) Statistics(ctx context.Context, query StatisticsQuery) (*StatisticsReportResponse, error) {
|
|
days := trendDays(query.Days)
|
|
if cached, ok := s.cachedStatistics(days); ok {
|
|
return cached, nil
|
|
}
|
|
|
|
cacheKey := strconv.Itoa(days)
|
|
out, err, _ := s.statisticsGroup.Do(cacheKey, func() (interface{}, error) {
|
|
if cached, ok := s.cachedStatistics(days); ok {
|
|
return cached, nil
|
|
}
|
|
|
|
startUnix := trendStartUnix(days)
|
|
|
|
s.logger.Info("Fetching dashboard statistics report", map[string]interface{}{
|
|
"days": days,
|
|
})
|
|
|
|
result, err := s.repo.Statistics(context.WithoutCancel(ctx), days, startUnix)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch dashboard statistics report", map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("dashboard statistics: %w", err)
|
|
}
|
|
|
|
s.storeStatistics(days, result)
|
|
return result, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return out.(*StatisticsReportResponse), nil
|
|
}
|
|
|
|
func (s *service) cachedSummary(windowSec int64) (*SummaryResponse, bool) {
|
|
now := time.Now()
|
|
|
|
s.summaryMu.Lock()
|
|
defer s.summaryMu.Unlock()
|
|
|
|
entry, ok := s.summary[windowSec]
|
|
if !ok || now.After(entry.expiresAt) {
|
|
if ok {
|
|
delete(s.summary, windowSec)
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
return entry.value, true
|
|
}
|
|
|
|
func (s *service) storeSummary(windowSec int64, value *SummaryResponse) {
|
|
s.summaryMu.Lock()
|
|
defer s.summaryMu.Unlock()
|
|
|
|
s.summary[windowSec] = cacheEntry[*SummaryResponse]{
|
|
expiresAt: time.Now().Add(summaryCacheTTL),
|
|
value: value,
|
|
}
|
|
}
|
|
|
|
func (s *service) cachedStatistics(days int) (*StatisticsReportResponse, bool) {
|
|
now := time.Now()
|
|
|
|
s.statisticsMu.Lock()
|
|
defer s.statisticsMu.Unlock()
|
|
|
|
entry, ok := s.statistics[days]
|
|
if !ok || now.After(entry.expiresAt) {
|
|
if ok {
|
|
delete(s.statistics, days)
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
return entry.value, true
|
|
}
|
|
|
|
func (s *service) storeStatistics(days int, value *StatisticsReportResponse) {
|
|
s.statisticsMu.Lock()
|
|
defer s.statisticsMu.Unlock()
|
|
|
|
s.statistics[days] = cacheEntry[*StatisticsReportResponse]{
|
|
expiresAt: time.Now().Add(statisticsCacheTTL),
|
|
value: value,
|
|
}
|
|
}
|
|
|
|
func closingWindowHours(hours int) int {
|
|
if hours <= 0 {
|
|
return defaultClosingWindowHours
|
|
}
|
|
return hours
|
|
}
|
|
|
|
func trendDays(days int) int {
|
|
if days <= 0 {
|
|
return defaultTrendDays
|
|
}
|
|
if days > maxTrendDays {
|
|
return maxTrendDays
|
|
}
|
|
return days
|
|
}
|
|
|
|
func countriesLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return defaultCountriesLimit
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func listLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return defaultListLimit
|
|
}
|
|
if limit > maxListLimit {
|
|
return maxListLimit
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func normalizeTrendMetric(metric string) string {
|
|
switch strings.ToLower(strings.TrimSpace(metric)) {
|
|
case "published", "awarded":
|
|
return strings.ToLower(strings.TrimSpace(metric))
|
|
default:
|
|
return "created"
|
|
}
|
|
}
|
|
|
|
func trendStartUnix(days int) int64 {
|
|
now := time.Now().UTC()
|
|
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
start := end.AddDate(0, 0, -(days - 1))
|
|
return start.Unix()
|
|
}
|
|
|
|
func fillTrendSeries(days int, counts map[string]int64) []TrendPoint {
|
|
now := time.Now().UTC()
|
|
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
start := end.AddDate(0, 0, -(days-1))
|
|
|
|
series := make([]TrendPoint, 0, days)
|
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
|
date := d.Format("2006-01-02")
|
|
series = append(series, TrendPoint{
|
|
Date: date,
|
|
Count: counts[date],
|
|
})
|
|
}
|
|
|
|
return series
|
|
}
|