From defa74de803d58bfa999390faea83cf59e05b282 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Mon, 18 May 2026 16:56:35 +0330 Subject: [PATCH] dashboard APIs --- cmd/web/main.go | 13 +- cmd/web/router/routes.go | 15 +- internal/dashboard/entity.go | 87 +++++ internal/dashboard/form.go | 29 ++ internal/dashboard/handler.go | 178 +++++++++ internal/dashboard/repository.go | 562 +++++++++++++++++++++++++++++ internal/dashboard/service.go | 225 ++++++++++++ internal/dashboard/service_test.go | 52 +++ 8 files changed, 1157 insertions(+), 4 deletions(-) create mode 100644 internal/dashboard/entity.go create mode 100644 internal/dashboard/form.go create mode 100644 internal/dashboard/handler.go create mode 100644 internal/dashboard/repository.go create mode 100644 internal/dashboard/service.go create mode 100644 internal/dashboard/service_test.go diff --git a/cmd/web/main.go b/cmd/web/main.go index 8c5fa9b..a20f02f 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -89,6 +89,9 @@ package main // @tag.name CMS // @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 ( "context" "fmt" @@ -100,6 +103,7 @@ import ( "tm/internal/company_category" "tm/internal/contact" "tm/internal/customer" + "tm/internal/dashboard" "tm/internal/document_scraper" "tm/internal/feedback" "tm/internal/inquiry" @@ -214,8 +218,10 @@ func main() { cmsService := cms.NewService(cmsRepository, logger) kanbanService := kanban.NewService(boardRepository, columnRepository, cardRepository, 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{}{ - "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 @@ -234,8 +240,9 @@ func main() { kanbanHandler := kanban.NewHandler(kanbanService) fileStoreHandler := filestore.NewHandler(fileStoreService, logger) documentScraperHandler := document_scraper.NewHandler(documentScraperService, logger) + dashboardHandler := dashboard.NewHandler(dashboardService) 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 @@ -243,7 +250,7 @@ func main() { // Register routes 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) // Start server diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index d51bc29..3adf509 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -7,6 +7,7 @@ import ( "tm/internal/company_category" "tm/internal/contact" "tm/internal/customer" + "tm/internal/dashboard" "tm/internal/document_scraper" "tm/internal/feedback" "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") // Add CSP middleware to public routes @@ -308,6 +309,18 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende 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 publicFilesGP := v1.Group("/files") { diff --git a/internal/dashboard/entity.go b/internal/dashboard/entity.go new file mode 100644 index 0000000..47e7d18 --- /dev/null +++ b/internal/dashboard/entity.go @@ -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"` +} diff --git a/internal/dashboard/form.go b/internal/dashboard/form.go new file mode 100644 index 0000000..7587a4e --- /dev/null +++ b/internal/dashboard/form.go @@ -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"` +} diff --git a/internal/dashboard/handler.go b/internal/dashboard/handler.go new file mode 100644 index 0000000..b45c003 --- /dev/null +++ b/internal/dashboard/handler.go @@ -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)) +} diff --git a/internal/dashboard/repository.go b/internal/dashboard/repository.go new file mode 100644 index 0000000..dcf7bfc --- /dev/null +++ b/internal/dashboard/repository.go @@ -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 +} diff --git a/internal/dashboard/service.go b/internal/dashboard/service.go new file mode 100644 index 0000000..e9841a8 --- /dev/null +++ b/internal/dashboard/service.go @@ -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 +} diff --git a/internal/dashboard/service_test.go b/internal/dashboard/service_test.go new file mode 100644 index 0000000..e5dbd41 --- /dev/null +++ b/internal/dashboard/service_test.go @@ -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) + } +}