dashboard APIs
This commit is contained in:
+10
-3
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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