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
+10 -3
View File
@@ -89,6 +89,9 @@ package main
// @tag.name CMS // @tag.name CMS
// @tag.description Public CMS management operations for mobile application including CMS listing and detailed CMS information // @tag.description Public CMS management operations for mobile application including CMS listing and detailed CMS information
// @tag.name Admin-Dashboard
// @tag.description Administrative dashboard analytics for the OppLens admin home page
import ( import (
"context" "context"
"fmt" "fmt"
@@ -100,6 +103,7 @@ import (
"tm/internal/company_category" "tm/internal/company_category"
"tm/internal/contact" "tm/internal/contact"
"tm/internal/customer" "tm/internal/customer"
"tm/internal/dashboard"
"tm/internal/document_scraper" "tm/internal/document_scraper"
"tm/internal/feedback" "tm/internal/feedback"
"tm/internal/inquiry" "tm/internal/inquiry"
@@ -214,8 +218,10 @@ func main() {
cmsService := cms.NewService(cmsRepository, logger) cmsService := cms.NewService(cmsRepository, logger)
kanbanService := kanban.NewService(boardRepository, columnRepository, cardRepository, logger) kanbanService := kanban.NewService(boardRepository, columnRepository, cardRepository, logger)
documentScraperService := document_scraper.NewService(tenderRepository, logger) documentScraperService := document_scraper.NewService(tenderRepository, logger)
dashboardRepository := dashboard.NewRepository(mongoManager, logger)
dashboardService := dashboard.NewService(dashboardRepository, logger)
logger.Info("Services initialized successfully", map[string]interface{}{ logger.Info("Services initialized successfully", map[string]interface{}{
"services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "document_scraper"}, "services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "document_scraper", "dashboard"},
}) })
// Initialize handlers with services // Initialize handlers with services
@@ -234,8 +240,9 @@ func main() {
kanbanHandler := kanban.NewHandler(kanbanService) kanbanHandler := kanban.NewHandler(kanbanService)
fileStoreHandler := filestore.NewHandler(fileStoreService, logger) fileStoreHandler := filestore.NewHandler(fileStoreService, logger)
documentScraperHandler := document_scraper.NewHandler(documentScraperService, logger) documentScraperHandler := document_scraper.NewHandler(documentScraperService, logger)
dashboardHandler := dashboard.NewHandler(dashboardService)
logger.Info("Handlers initialized successfully", map[string]interface{}{ logger.Info("Handlers initialized successfully", map[string]interface{}{
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore", "document_scraper"}, "handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore", "document_scraper", "dashboard"},
}) })
// Initialize HTTP server // Initialize HTTP server
@@ -243,7 +250,7 @@ func main() {
// Register routes // Register routes
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy) router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy)
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, fileStoreHandler, xssPolicy) router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, fileStoreHandler, dashboardHandler, userHandler, xssPolicy)
router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey) router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey)
// Start server // Start server
+14 -1
View File
@@ -7,6 +7,7 @@ import (
"tm/internal/company_category" "tm/internal/company_category"
"tm/internal/contact" "tm/internal/contact"
"tm/internal/customer" "tm/internal/customer"
"tm/internal/dashboard"
"tm/internal/document_scraper" "tm/internal/document_scraper"
"tm/internal/feedback" "tm/internal/feedback"
"tm/internal/inquiry" "tm/internal/inquiry"
@@ -204,7 +205,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
} }
} }
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) { func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, userHandler *user.Handler, xssPolicy *security.XSSPolicy) {
v1 := e.Group("/api/v1") v1 := e.Group("/api/v1")
// Add CSP middleware to public routes // Add CSP middleware to public routes
@@ -308,6 +309,18 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
cmsGP.GET("/:key", cmsHandler.GetByKey) cmsGP.GET("/:key", cmsHandler.GetByKey)
} }
// Dashboard Routes (admin auth on public API prefix per dashboard-api.md)
dashboardGP := v1.Group("/dashboard")
{
dashboardGP.Use(userHandler.AuthMiddleware())
dashboardGP.GET("/summary", dashboardHandler.Summary)
dashboardGP.GET("/trend", dashboardHandler.Trend)
dashboardGP.GET("/countries", dashboardHandler.Countries)
dashboardGP.GET("/notice-types", dashboardHandler.NoticeTypes)
dashboardGP.GET("/closing-soon", dashboardHandler.ClosingSoon)
dashboardGP.GET("/recent", dashboardHandler.Recent)
}
// File Store Routes // File Store Routes
publicFilesGP := v1.Group("/files") publicFilesGP := v1.Group("/files")
{ {
+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)
}
}