2e112fe08c
continuous-integration/drone/push Build is passing
- Changed the default MinIO bucket name from "opplens-documents" to "opplens" across multiple configuration files. - Introduced caching for dashboard statistics in the service layer to improve performance and reduce redundant data fetching. - Implemented a mutex for thread-safe access to cached statistics, ensuring data integrity during concurrent requests. This update streamlines the configuration for the AI summarizer and optimizes the dashboard service, enhancing overall system efficiency.
291 lines
7.2 KiB
Go
291 lines
7.2 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"tm/pkg/logger"
|
|
)
|
|
|
|
const (
|
|
defaultClosingWindowHours = 168
|
|
defaultTrendDays = 14
|
|
maxTrendDays = 90
|
|
defaultCountriesLimit = 6
|
|
defaultListLimit = 5
|
|
maxListLimit = 20
|
|
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 statisticsCacheEntry struct {
|
|
expiresAt time.Time
|
|
value *StatisticsReportResponse
|
|
}
|
|
|
|
type service struct {
|
|
repo Repository
|
|
logger logger.Logger
|
|
statisticsMu sync.Mutex
|
|
statistics map[int]statisticsCacheEntry
|
|
}
|
|
|
|
// NewService creates a dashboard service.
|
|
func NewService(repo Repository, log logger.Logger) Service {
|
|
return &service{
|
|
repo: repo,
|
|
logger: log,
|
|
statistics: make(map[int]statisticsCacheEntry),
|
|
}
|
|
}
|
|
|
|
func (s *service) Summary(ctx context.Context, query SummaryQuery) (*SummaryResponse, error) {
|
|
windowHours := closingWindowHours(query.ClosingWindow)
|
|
windowSec := int64(windowHours) * 3600
|
|
|
|
s.logger.Info("Fetching dashboard summary", map[string]interface{}{
|
|
"closing_window_hours": windowHours,
|
|
})
|
|
|
|
out, err := s.repo.Summary(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)
|
|
}
|
|
|
|
return out, 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
|
|
}
|
|
|
|
startUnix := trendStartUnix(days)
|
|
|
|
s.logger.Info("Fetching dashboard statistics report", map[string]interface{}{
|
|
"days": days,
|
|
})
|
|
|
|
out, err := s.repo.Statistics(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, out)
|
|
return out, nil
|
|
}
|
|
|
|
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] = statisticsCacheEntry{
|
|
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
|
|
}
|