dashboard APIs
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user