dashboard APIs

This commit is contained in:
Mazyar
2026-05-18 16:56:35 +03:30
parent 6dac5b482a
commit defa74de80
8 changed files with 1157 additions and 4 deletions
+87
View File
@@ -0,0 +1,87 @@
package dashboard
// SummaryResponse powers hero pills and stat cards.
type SummaryResponse struct {
TotalTenders int64 `json:"total_tenders"`
ActiveTenders int64 `json:"active_tenders"`
AwardedTenders int64 `json:"awarded_tenders"`
ExpiredTenders int64 `json:"expired_tenders"`
CancelledTenders int64 `json:"cancelled_tenders"`
ClosingSoon int64 `json:"closing_soon"`
TotalEstimatedValue float64 `json:"total_estimated_value"`
ValueCurrency string `json:"value_currency"`
GeneratedAt int64 `json:"generated_at"`
}
// TrendResponse powers the tender flow chart.
type TrendResponse struct {
Metric string `json:"metric"`
Days int `json:"days"`
Series []TrendPoint `json:"series"`
}
// TrendPoint is a single day in the trend series.
type TrendPoint struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
// CountriesResponse powers the country distribution widget.
type CountriesResponse struct {
Total int64 `json:"total"`
Items []CountryItem `json:"items"`
OtherCount int64 `json:"other_count"`
}
// CountryItem is a country bucket in the distribution.
type CountryItem struct {
CountryCode string `json:"country_code"`
Count int64 `json:"count"`
}
// NoticeTypesResponse powers the notice type mix widget.
type NoticeTypesResponse struct {
Total int64 `json:"total"`
Items []NoticeTypeItem `json:"items"`
}
// NoticeTypeItem is a notice type bucket.
type NoticeTypeItem struct {
Type string `json:"type"`
Count int64 `json:"count"`
}
// ClosingSoonResponse powers the closing soon card.
type ClosingSoonResponse struct {
Items []ClosingSoonItem `json:"items"`
}
// ClosingSoonItem is a tender closing within the configured window.
type ClosingSoonItem struct {
ID string `json:"id"`
Title string `json:"title"`
CountryCode string `json:"country_code"`
BuyerName string `json:"buyer_name"`
SubmissionDeadline int64 `json:"submission_deadline"`
TenderDeadline int64 `json:"tender_deadline"`
Status string `json:"status"`
EstimatedValue float64 `json:"estimated_value"`
Currency string `json:"currency"`
}
// RecentResponse powers the recent tenders feed.
type RecentResponse struct {
Items []RecentItem `json:"items"`
NextCursor *string `json:"next_cursor"`
}
// RecentItem is a row in the recent tenders list.
type RecentItem struct {
ID string `json:"id"`
Title string `json:"title"`
CountryCode string `json:"country_code"`
Status string `json:"status"`
BuyerName string `json:"buyer_name"`
CreatedAt int64 `json:"created_at"`
PublicationDate int64 `json:"publication_date"`
}
+29
View File
@@ -0,0 +1,29 @@
package dashboard
// SummaryQuery binds query params for GET /dashboard/summary.
type SummaryQuery struct {
ClosingWindow int `query:"closing_window"`
}
// TrendQuery binds query params for GET /dashboard/trend.
type TrendQuery struct {
Days int `query:"days"`
Metric string `query:"metric"`
}
// CountriesQuery binds query params for GET /dashboard/countries.
type CountriesQuery struct {
Limit int `query:"limit"`
}
// ClosingSoonQuery binds query params for GET /dashboard/closing-soon.
type ClosingSoonQuery struct {
Limit int `query:"limit"`
Window int `query:"window"`
}
// RecentQuery binds query params for GET /dashboard/recent.
type RecentQuery struct {
Limit int `query:"limit"`
Cursor string `query:"cursor"`
}
+178
View File
@@ -0,0 +1,178 @@
package dashboard
import (
"strconv"
"tm/pkg/response"
"github.com/labstack/echo/v4"
)
// Handler handles dashboard HTTP requests.
type Handler struct {
service Service
}
// NewHandler creates a dashboard handler.
func NewHandler(service Service) *Handler {
return &Handler{service: service}
}
// Summary returns top-level dashboard counters.
// @Summary Dashboard summary
// @Description Top-level counters for hero pills and stat cards
// @Tags Admin-Dashboard
// @Produce json
// @Param closing_window query int false "Hours considered closing soon (default 168)"
// @Success 200 {object} response.APIResponse{data=SummaryResponse}
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/dashboard/summary [get]
// @Security BearerAuth
func (h *Handler) Summary(c echo.Context) error {
query := SummaryQuery{
ClosingWindow: parseIntQuery(c, "closing_window", 0),
}
out, err := h.service.Summary(c.Request().Context(), query)
if err != nil {
return response.InternalServerError(c, "Failed to load dashboard summary")
}
setPrivateCache(c, 60)
return response.Success(c, out, "Dashboard summary retrieved successfully")
}
// Trend returns per-day tender counts for the flow chart.
// @Summary Dashboard trend
// @Description Per-day counts for the tender flow chart
// @Tags Admin-Dashboard
// @Produce json
// @Param days query int false "Days back (default 14, max 90)"
// @Param metric query string false "created|published|awarded (default created)"
// @Success 200 {object} response.APIResponse{data=TrendResponse}
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/dashboard/trend [get]
// @Security BearerAuth
func (h *Handler) Trend(c echo.Context) error {
query := TrendQuery{
Days: parseIntQuery(c, "days", 0),
Metric: c.QueryParam("metric"),
}
out, err := h.service.Trend(c.Request().Context(), query)
if err != nil {
return response.InternalServerError(c, "Failed to load dashboard trend")
}
setPrivateCache(c, 60)
return response.Success(c, out, "Dashboard trend retrieved successfully")
}
// Countries returns country distribution buckets.
// @Summary Dashboard countries
// @Description Country distribution for donut and ranking list
// @Tags Admin-Dashboard
// @Produce json
// @Param limit query int false "Top N countries (default 6)"
// @Success 200 {object} response.APIResponse{data=CountriesResponse}
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/dashboard/countries [get]
// @Security BearerAuth
func (h *Handler) Countries(c echo.Context) error {
query := CountriesQuery{
Limit: parseIntQuery(c, "limit", 0),
}
out, err := h.service.Countries(c.Request().Context(), query)
if err != nil {
return response.InternalServerError(c, "Failed to load dashboard countries")
}
setPrivateCache(c, 60)
return response.Success(c, out, "Dashboard countries retrieved successfully")
}
// NoticeTypes returns notice type distribution.
// @Summary Dashboard notice types
// @Description Notice type mix for stacked bars
// @Tags Admin-Dashboard
// @Produce json
// @Success 200 {object} response.APIResponse{data=NoticeTypesResponse}
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/dashboard/notice-types [get]
// @Security BearerAuth
func (h *Handler) NoticeTypes(c echo.Context) error {
out, err := h.service.NoticeTypes(c.Request().Context())
if err != nil {
return response.InternalServerError(c, "Failed to load dashboard notice types")
}
setPrivateCache(c, 60)
return response.Success(c, out, "Dashboard notice types retrieved successfully")
}
// ClosingSoon returns tenders closing within the configured window.
// @Summary Dashboard closing soon
// @Description Actionable tenders sorted by urgency
// @Tags Admin-Dashboard
// @Produce json
// @Param limit query int false "Max rows (default 5, max 20)"
// @Param window query int false "Hours horizon (default 168)"
// @Success 200 {object} response.APIResponse{data=ClosingSoonResponse}
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/dashboard/closing-soon [get]
// @Security BearerAuth
func (h *Handler) ClosingSoon(c echo.Context) error {
query := ClosingSoonQuery{
Limit: parseIntQuery(c, "limit", 0),
Window: parseIntQuery(c, "window", 0),
}
out, err := h.service.ClosingSoon(c.Request().Context(), query)
if err != nil {
return response.InternalServerError(c, "Failed to load closing soon tenders")
}
return response.Success(c, out, "Closing soon tenders retrieved successfully")
}
// Recent returns the most recently created tenders.
// @Summary Dashboard recent tenders
// @Description Recent tenders feed sorted by created_at
// @Tags Admin-Dashboard
// @Produce json
// @Param limit query int false "Max rows (default 5, max 20)"
// @Param cursor query string false "Opaque pagination cursor"
// @Success 200 {object} response.APIResponse{data=RecentResponse}
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/dashboard/recent [get]
// @Security BearerAuth
func (h *Handler) Recent(c echo.Context) error {
query := RecentQuery{
Limit: parseIntQuery(c, "limit", 0),
Cursor: c.QueryParam("cursor"),
}
out, err := h.service.Recent(c.Request().Context(), query)
if err != nil {
return response.InternalServerError(c, "Failed to load recent tenders")
}
setPrivateCache(c, 60)
return response.Success(c, out, "Recent tenders retrieved successfully")
}
func parseIntQuery(c echo.Context, name string, fallback int) int {
raw := c.QueryParam(name)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return v
}
func setPrivateCache(c echo.Context, maxAge int) {
c.Response().Header().Set("Cache-Control", "private, max-age="+strconv.Itoa(maxAge))
}
+562
View File
@@ -0,0 +1,562 @@
package dashboard
import (
"context"
"fmt"
"strings"
"time"
"tm/internal/tender"
"tm/pkg/logger"
orm "tm/pkg/mongo"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
const defaultValueCurrency = "EUR"
// Repository defines dashboard data access.
type Repository interface {
Summary(ctx context.Context, closingWindowSec int64) (*SummaryResponse, error)
Trend(ctx context.Context, days int, metric string, startUnix int64) (map[string]int64, error)
Countries(ctx context.Context, limit int) (*CountriesResponse, error)
NoticeTypes(ctx context.Context) (*NoticeTypesResponse, error)
ClosingSoon(ctx context.Context, limit int, windowSec int64) ([]ClosingSoonItem, error)
Recent(ctx context.Context, limit int, cursor string) ([]RecentItem, *string, error)
}
type repository struct {
ormRepo orm.Repository[tender.Tender]
logger logger.Logger
}
// NewRepository creates a dashboard repository backed by the tenders collection.
func NewRepository(mongoManager *orm.ConnectionManager, log logger.Logger) Repository {
return &repository{
ormRepo: orm.NewRepository[tender.Tender](mongoManager.GetCollection(tenderCollectionName()), log),
logger: log,
}
}
func tenderCollectionName() string {
return "tenders"
}
func (r *repository) Summary(ctx context.Context, closingWindowSec int64) (*SummaryResponse, error) {
now := time.Now().Unix()
windowEnd := now + closingWindowSec
total, err := r.ormRepo.Count(ctx, bson.M{})
if err != nil {
return nil, fmt.Errorf("count total tenders: %w", err)
}
active, err := r.ormRepo.Count(ctx, bson.M{"status": tender.TenderStatusActive})
if err != nil {
return nil, fmt.Errorf("count active tenders: %w", err)
}
awarded, err := r.ormRepo.Count(ctx, bson.M{"status": tender.TenderStatusAwarded})
if err != nil {
return nil, fmt.Errorf("count awarded tenders: %w", err)
}
expired, err := r.countExpired(ctx, now)
if err != nil {
return nil, err
}
cancelled, err := r.ormRepo.Count(ctx, bson.M{"status": tender.TenderStatusCancelled})
if err != nil {
return nil, fmt.Errorf("count cancelled tenders: %w", err)
}
closingSoon, err := r.countClosingSoon(ctx, now, windowEnd)
if err != nil {
return nil, err
}
valueCurrency, totalValue, err := r.dominantCurrencyValue(ctx)
if err != nil {
return nil, err
}
return &SummaryResponse{
TotalTenders: total,
ActiveTenders: active,
AwardedTenders: awarded,
ExpiredTenders: expired,
CancelledTenders: cancelled,
ClosingSoon: closingSoon,
TotalEstimatedValue: totalValue,
ValueCurrency: valueCurrency,
GeneratedAt: now,
}, nil
}
func (r *repository) countExpired(ctx context.Context, now int64) (int64, error) {
pipeline := mongo.Pipeline{
{{Key: "$addFields", Value: bson.M{"expiry_deadline": expiryDeadlineExpr()}}},
{{Key: "$match", Value: bson.M{
"status": bson.M{"$nin": []tender.TenderStatus{
tender.TenderStatusAwarded,
tender.TenderStatusCancelled,
}},
"$or": []bson.M{
{"status": tender.TenderStatusExpired},
{
"expiry_deadline": bson.M{"$gt": 0, "$lte": now},
},
},
}}},
{{Key: "$count", Value: "count"}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return 0, fmt.Errorf("count expired tenders: %w", err)
}
if len(results) == 0 {
return 0, nil
}
return aggregateCount(results[0], "count"), nil
}
func (r *repository) countClosingSoon(ctx context.Context, now, windowEnd int64) (int64, error) {
pipeline := mongo.Pipeline{
{{Key: "$addFields", Value: bson.M{"effective_deadline": effectiveDeadlineExpr()}}},
{{Key: "$match", Value: bson.M{
"effective_deadline": bson.M{"$gt": now, "$lte": windowEnd},
}}},
{{Key: "$count", Value: "count"}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return 0, fmt.Errorf("count closing soon: %w", err)
}
if len(results) == 0 {
return 0, nil
}
return aggregateCount(results[0], "count"), nil
}
func (r *repository) dominantCurrencyValue(ctx context.Context) (string, float64, error) {
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.M{
"estimated_value": bson.M{"$gt": 0},
"currency": bson.M{"$type": "string", "$ne": ""},
}}},
{{Key: "$group", Value: bson.M{
"_id": "$currency",
"count": bson.M{"$sum": 1},
"total": bson.M{"$sum": "$estimated_value"},
}}},
{{Key: "$sort", Value: bson.M{"count": -1}}},
{{Key: "$limit", Value: 1}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return defaultValueCurrency, 0, fmt.Errorf("dominant currency value: %w", err)
}
if len(results) == 0 {
return defaultValueCurrency, 0, nil
}
currency := defaultValueCurrency
if c, ok := results[0]["_id"].(string); ok && c != "" {
currency = strings.ToUpper(c)
}
return currency, toFloat64(results[0]["total"]), nil
}
func (r *repository) Trend(ctx context.Context, days int, metric string, startUnix int64) (map[string]int64, error) {
field := trendTimestampField(metric)
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.M{
field: bson.M{"$gte": startUnix, "$gt": 0},
}}},
{{Key: "$addFields", Value: bson.M{
"metric_ts": normalizeTimestampExpr(field),
}}},
{{Key: "$group", Value: bson.M{
"_id": bson.M{
"$dateToString": bson.M{
"format": "%Y-%m-%d",
"date": bson.M{"$toDate": bson.M{"$multiply": bson.A{"$metric_ts", 1000}}},
"timezone": "UTC",
},
},
"count": bson.M{"$sum": 1},
}}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, fmt.Errorf("trend aggregation: %w", err)
}
counts := make(map[string]int64, len(results))
for _, row := range results {
date, ok := row["_id"].(string)
if !ok || date == "" {
continue
}
counts[date] = aggregateCount(row, "count")
}
return counts, nil
}
func (r *repository) Countries(ctx context.Context, limit int) (*CountriesResponse, error) {
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.M{
"country_code": bson.M{"$type": "string", "$ne": ""},
}}},
{{Key: "$group", Value: bson.M{
"_id": bson.M{"$toUpper": "$country_code"},
"count": bson.M{"$sum": 1},
}}},
{{Key: "$sort", Value: bson.M{"count": -1}}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, fmt.Errorf("countries aggregation: %w", err)
}
var total int64
items := make([]CountryItem, 0, limit)
var other int64
for i, row := range results {
code, _ := row["_id"].(string)
count := aggregateCount(row, "count")
total += count
if code == "" {
other += count
continue
}
if i < limit {
items = append(items, CountryItem{CountryCode: code, Count: count})
} else {
other += count
}
}
return &CountriesResponse{
Total: total,
Items: items,
OtherCount: other,
}, nil
}
func (r *repository) NoticeTypes(ctx context.Context) (*NoticeTypesResponse, error) {
pipeline := mongo.Pipeline{
{{Key: "$group", Value: bson.M{
"_id": bson.M{
"$cond": bson.A{
bson.M{"$and": bson.A{
bson.M{"$ne": bson.A{"$notice_type_code", ""}},
bson.M{"$ne": bson.A{"$notice_type_code", nil}},
}},
"$notice_type_code",
"UNKNOWN",
},
},
"count": bson.M{"$sum": 1},
}}},
{{Key: "$sort", Value: bson.M{"count": -1}}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, fmt.Errorf("notice types aggregation: %w", err)
}
items := make([]NoticeTypeItem, 0, len(results))
var total int64
for _, row := range results {
noticeType, _ := row["_id"].(string)
count := aggregateCount(row, "count")
total += count
items = append(items, NoticeTypeItem{Type: noticeType, Count: count})
}
return &NoticeTypesResponse{Total: total, Items: items}, nil
}
func (r *repository) ClosingSoon(ctx context.Context, limit int, windowSec int64) ([]ClosingSoonItem, error) {
now := time.Now().Unix()
windowEnd := now + windowSec
pipeline := mongo.Pipeline{
{{Key: "$addFields", Value: bson.M{"effective_deadline": effectiveDeadlineExpr()}}},
{{Key: "$match", Value: bson.M{
"effective_deadline": bson.M{"$gt": now, "$lte": windowEnd},
}}},
{{Key: "$sort", Value: bson.M{"effective_deadline": 1}}},
{{Key: "$limit", Value: limit}},
{{Key: "$project", Value: bson.M{
"_id": 1,
"title": 1,
"country_code": 1,
"buyer_organization": 1,
"submission_deadline": 1,
"tender_deadline": 1,
"status": 1,
"estimated_value": 1,
"currency": 1,
"effective_deadline": 1,
}}},
}
results, err := r.ormRepo.Aggregate(ctx, pipeline)
if err != nil {
return nil, fmt.Errorf("closing soon list: %w", err)
}
items := make([]ClosingSoonItem, 0, len(results))
for _, row := range results {
items = append(items, mapClosingSoonRow(row))
}
return items, nil
}
func (r *repository) Recent(ctx context.Context, limit int, cursor string) ([]RecentItem, *string, error) {
filter := bson.M{}
mongoPagination, err := orm.BuildListPagination(
limit,
0,
cursor,
"created_at",
"desc",
filter,
orm.ListPaginationOptions{
Projection: bson.M{
"title": 1,
"country_code": 1,
"status": 1,
"buyer_organization": 1,
"created_at": 1,
"publication_date": 1,
},
},
)
if err != nil {
return nil, nil, fmt.Errorf("recent pagination: %w", err)
}
result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
if err != nil {
return nil, nil, fmt.Errorf("recent list: %w", err)
}
items := make([]RecentItem, 0, len(result.Items))
for _, t := range result.Items {
items = append(items, mapRecentTender(&t))
}
var nextCursor *string
if result.NextCursor != "" {
nextCursor = &result.NextCursor
}
return items, nextCursor, nil
}
func mapClosingSoonRow(row bson.M) ClosingSoonItem {
item := ClosingSoonItem{
Title: stringField(row, "title"),
CountryCode: strings.ToUpper(stringField(row, "country_code")),
Status: stringField(row, "status"),
EstimatedValue: toFloat64(row["estimated_value"]),
Currency: strings.ToUpper(stringField(row, "currency")),
}
if id, ok := row["_id"]; ok {
item.ID = objectIDString(id)
}
item.SubmissionDeadline = normalizeUnixTimestamp(int64Field(row, "submission_deadline"))
item.TenderDeadline = normalizeUnixTimestamp(int64Field(row, "tender_deadline"))
if effective := int64Field(row, "effective_deadline"); effective > 0 && item.SubmissionDeadline == 0 {
item.SubmissionDeadline = effective
}
if buyer, ok := row["buyer_organization"].(bson.M); ok {
item.BuyerName = stringField(buyer, "name")
} else if buyer, ok := row["buyer_organization"].(map[string]interface{}); ok {
if name, ok := buyer["name"].(string); ok {
item.BuyerName = name
}
}
return item
}
func mapRecentTender(t *tender.Tender) RecentItem {
item := RecentItem{
ID: t.GetID(),
Title: t.Title,
CountryCode: strings.ToUpper(t.CountryCode),
Status: string(t.Status),
CreatedAt: normalizeUnixTimestamp(t.CreatedAt),
PublicationDate: normalizeUnixTimestamp(t.PublicationDate),
}
if t.BuyerOrganization != nil {
item.BuyerName = t.BuyerOrganization.Name
}
if item.CreatedAt == 0 && item.PublicationDate > 0 {
item.CreatedAt = item.PublicationDate
}
return item
}
func trendTimestampField(metric string) string {
switch strings.ToLower(strings.TrimSpace(metric)) {
case "published":
return "publication_date"
case "awarded":
return "award_date"
default:
return "created_at"
}
}
// effectiveDeadlineExpr is used for closing-soon (submission first, per dashboard-api.md).
func effectiveDeadlineExpr() bson.M {
return normalizeDeadlineFieldExpr(
"$submission_deadline",
"$tender_deadline",
)
}
// expiryDeadlineExpr is used for expired counts (tender deadline takes precedence).
func expiryDeadlineExpr() bson.M {
return normalizeDeadlineFieldExpr(
"$tender_deadline",
"$submission_deadline",
)
}
func normalizeDeadlineFieldExpr(primaryField, fallbackField string) bson.M {
return bson.M{
"$let": bson.M{
"vars": bson.M{
"raw": bson.M{
"$cond": bson.A{
bson.M{"$gt": bson.A{primaryField, 0}},
primaryField,
fallbackField,
},
},
},
"in": normalizeTimestampExpr("$$raw"),
},
}
}
func normalizeTimestampExpr(field string) bson.M {
ref := field
if !strings.HasPrefix(ref, "$") {
ref = "$" + ref
}
return bson.M{
"$cond": bson.A{
bson.M{"$gt": bson.A{ref, int64(1_000_000_000_000)}},
bson.M{"$divide": bson.A{ref, 1000}},
ref,
},
}
}
func aggregateCount(row bson.M, field string) int64 {
if row == nil {
return 0
}
if field != "" {
if v, ok := row[field]; ok {
return toInt64(v)
}
}
if v, ok := row["count"]; ok {
return toInt64(v)
}
return 0
}
func toInt64(v interface{}) int64 {
switch n := v.(type) {
case int32:
return int64(n)
case int64:
return n
case float64:
return int64(n)
case int:
return int64(n)
default:
return 0
}
}
func toFloat64(v interface{}) float64 {
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int32:
return float64(n)
case int64:
return float64(n)
case int:
return float64(n)
default:
return 0
}
}
func int64Field(row bson.M, key string) int64 {
return toInt64(row[key])
}
func stringField(row bson.M, key string) string {
if v, ok := row[key].(string); ok {
return v
}
return ""
}
func objectIDString(v interface{}) string {
switch id := v.(type) {
case bson.ObjectID:
return id.Hex()
default:
return fmt.Sprint(v)
}
}
func normalizeUnixTimestamp(value int64) int64 {
if value > 1_000_000_000_000 {
return value / 1000
}
return value
}
+225
View File
@@ -0,0 +1,225 @@
package dashboard
import (
"context"
"fmt"
"strings"
"time"
"tm/pkg/logger"
)
const (
defaultClosingWindowHours = 168
defaultTrendDays = 14
maxTrendDays = 90
defaultCountriesLimit = 6
defaultListLimit = 5
maxListLimit = 20
)
// 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)
}
type service struct {
repo Repository
logger logger.Logger
}
// NewService creates a dashboard service.
func NewService(repo Repository, log logger.Logger) Service {
return &service{repo: repo, logger: log}
}
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 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
}
+52
View File
@@ -0,0 +1,52 @@
package dashboard
import (
"testing"
"time"
)
func TestFillTrendSeriesIncludesZeroDays(t *testing.T) {
now := time.Now().UTC()
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
mid := end.AddDate(0, 0, -1).Format("2006-01-02")
counts := map[string]int64{mid: 3}
series := fillTrendSeries(3, counts)
if len(series) != 3 {
t.Fatalf("expected 3 points, got %d", len(series))
}
var foundMid bool
for _, point := range series {
if point.Date == mid && point.Count == 3 {
foundMid = true
}
}
if !foundMid {
t.Fatalf("expected middle day count 3, series=%+v", series)
}
if series[0].Count != 0 || series[2].Count != 0 {
t.Fatalf("expected zero-filled edge days, series=%+v", series)
}
}
func TestNormalizeTrendMetric(t *testing.T) {
if got := normalizeTrendMetric("PUBLISHED"); got != "published" {
t.Fatalf("expected published, got %q", got)
}
if got := normalizeTrendMetric(""); got != "created" {
t.Fatalf("expected created default, got %q", got)
}
}
func TestListLimitBounds(t *testing.T) {
if got := listLimit(0); got != defaultListLimit {
t.Fatalf("expected default %d, got %d", defaultListLimit, got)
}
if got := listLimit(99); got != maxListLimit {
t.Fatalf("expected max %d, got %d", maxListLimit, got)
}
}