package handler import ( "context" "net/http" "strconv" "time" "bugulma/backend/internal/domain" "bugulma/backend/internal/matching" "bugulma/backend/internal/matching/engine" "bugulma/backend/internal/service" "github.com/gin-gonic/gin" ) type contextKey string const ( userIDKey contextKey = "user_id" orgIDKey contextKey = "org_id" ) func getUserFromContext(ctx context.Context) string { if userID, ok := ctx.Value(userIDKey).(string); ok { return userID } return "system" // Default fallback } // MatchQueryRequest matches the concept schema match_query_request.json type MatchQueryRequest struct { Resource struct { Type string `json:"type" binding:"required"` Direction string `json:"direction" binding:"required"` SiteID string `json:"site_id,omitempty"` TemperatureRange *struct { MinCelsius float64 `json:"min_celsius"` MaxCelsius float64 `json:"max_celsius"` } `json:"temperature_range,omitempty"` QuantityRange *struct { MinAmount float64 `json:"min_amount"` MaxAmount float64 `json:"max_amount"` Unit string `json:"unit"` } `json:"quantity_range,omitempty"` } `json:"resource" binding:"required"` Constraints struct { MaxDistanceKm float64 `json:"max_distance_km"` MinEconomicValue float64 `json:"min_economic_value"` PrecisionPreference []string `json:"precision_preference"` IncludeServices bool `json:"include_services"` } `json:"constraints"` Pagination struct { Limit int `json:"limit"` Offset int `json:"offset"` } `json:"pagination"` } // MatchResponse matches the concept schema match_response.json type MatchResponse struct { Matches []MatchResult `json:"matches"` Metadata MatchMetadata `json:"metadata"` } type MatchResult struct { ID string `json:"id"` CompatibilityScore float64 `json:"compatibility_score"` EconomicValue float64 `json:"economic_value"` DistanceKm float64 `json:"distance_km"` SourceResource *domain.ResourceFlow `json:"source_resource"` TargetResource *domain.ResourceFlow `json:"target_resource"` TransportationEstimate map[string]interface{} `json:"transportation_estimate,omitempty"` RiskAssessment map[string]interface{} `json:"risk_assessment,omitempty"` EconomicImpact map[string]interface{} `json:"economic_impact,omitempty"` PartnerPacketURL string `json:"partner_packet_url,omitempty"` } type MatchMetadata struct { TotalCount int `json:"total_count"` QueryTimeMs int64 `json:"query_time_ms"` CacheHit bool `json:"cache_hit"` PrecisionLevels map[string]int `json:"precision_levels"` } // MatchingHandler provides comprehensive matching API with multi-stage pipeline type MatchingHandler struct { matchingService *matching.Service cacheService service.CacheService } func NewMatchingHandler(matchingService *matching.Service, cacheService service.CacheService) *MatchingHandler { return &MatchingHandler{ matchingService: matchingService, cacheService: cacheService, } } // FindMatches implements the match query API from concept spec // POST /api/matching/query func (h *MatchingHandler) FindMatches(c *gin.Context) { startTime := time.Now() var req MatchQueryRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Set defaults if req.Constraints.MaxDistanceKm == 0 { req.Constraints.MaxDistanceKm = 25.0 // Concept spec default } if req.Constraints.MinEconomicValue == 0 { req.Constraints.MinEconomicValue = 1000.0 // €1k minimum } if req.Pagination.Limit == 0 { req.Pagination.Limit = 20 // Concept spec default } // Try to get from cache first (Fast Matching <100ms) var cacheKey string var candidates []*engine.Candidate var cacheHit bool if req.Resource.SiteID != "" { // Get site location for cache key site, err := h.matchingService.GetSiteByID(c.Request.Context(), req.Resource.SiteID) if err == nil { cacheKey = service.GenerateCacheKey( req.Resource.Type, site.Latitude, site.Longitude, req.Constraints.MaxDistanceKm, ) cachedMatches, err := h.cacheService.GetMatches(c.Request.Context(), cacheKey) if err == nil { candidates = cachedMatches cacheHit = true } } } // If cache miss, compute matches (Standard Matching <5s) if !cacheHit { criteria := matching.Criteria{ ResourceType: domain.ResourceType(req.Resource.Type), MaxDistanceKm: req.Constraints.MaxDistanceKm, MinCompatibility: 0.6, // Concept spec default MinEconomicScore: 0.5, // Concept spec default MinTemporalScore: 0.4, // Concept spec default MinQualityScore: 0.4, // Concept spec default } var err error candidates, err = h.matchingService.FindMatches(c.Request.Context(), criteria) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Cache results for 15 minutes (concept spec recommendation) if cacheKey != "" { _ = h.cacheService.SetMatches(c.Request.Context(), cacheKey, candidates, 15*time.Minute) } } // Apply pagination totalCount := len(candidates) start := req.Pagination.Offset end := start + req.Pagination.Limit if end > totalCount { end = totalCount } if start > totalCount { start = totalCount } paginatedCandidates := candidates[start:end] // Convert to response format matches := make([]MatchResult, 0, len(paginatedCandidates)) precisionLevels := map[string]int{ "measured": 0, "estimated": 0, "rough": 0, } for _, candidate := range paginatedCandidates { // Count precision levels precisionLevels[string(candidate.SourceFlow.PrecisionLevel)]++ match := MatchResult{ ID: candidate.SourceFlow.ID + "-" + candidate.TargetFlow.ID, CompatibilityScore: candidate.CompatibilityScore, EconomicValue: candidate.EconomicScore * 10000, // Scale to annual € DistanceKm: candidate.DistanceKm, SourceResource: candidate.SourceFlow, TargetResource: candidate.TargetFlow, } matches = append(matches, match) } queryTime := time.Since(startTime).Milliseconds() response := MatchResponse{ Matches: matches, Metadata: MatchMetadata{ TotalCount: totalCount, QueryTimeMs: queryTime, CacheHit: cacheHit, PrecisionLevels: precisionLevels, }, } c.JSON(http.StatusOK, response) } // GetMatchDetails retrieves detailed information about a specific match // GET /api/matching/:matchId func (h *MatchingHandler) GetMatchDetails(c *gin.Context) { matchID := c.Param("matchId") match, err := h.matchingService.GetMatchByID(c.Request.Context(), matchID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Match not found"}) return } c.JSON(http.StatusOK, match) } // CreateMatchFromQuery creates a match from a query result // POST /api/matching/create-from-query func (h *MatchingHandler) CreateMatchFromQuery(c *gin.Context) { var req struct { SourceFlowID string `json:"source_flow_id" binding:"required"` TargetFlowID string `json:"target_flow_id" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Get flows sourceFlow, err := h.matchingService.GetResourceFlowByID(c.Request.Context(), req.SourceFlowID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Source flow not found"}) return } targetFlow, err := h.matchingService.GetResourceFlowByID(c.Request.Context(), req.TargetFlowID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Target flow not found"}) return } // Get sites for distance calculation sourceSite, err := h.matchingService.GetSiteByID(c.Request.Context(), sourceFlow.SiteID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source site"}) return } targetSite, err := h.matchingService.GetSiteByID(c.Request.Context(), targetFlow.SiteID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get target site"}) return } // Create candidate with full scoring candidate := &engine.Candidate{ SourceFlow: sourceFlow, TargetFlow: targetFlow, DistanceKm: h.matchingService.CalculateDistance(sourceSite.Latitude, sourceSite.Longitude, targetSite.Latitude, targetSite.Longitude), CompatibilityScore: 0.8, // Placeholder - should call matching service EconomicScore: 0.7, TemporalScore: 0.9, QualityScore: 0.85, OverallScore: 0.8, } // Create match creatorID := getUserFromContext(c.Request.Context()) match, err := h.matchingService.CreateMatch(c.Request.Context(), candidate, creatorID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Invalidate cache for both resources _ = h.cacheService.InvalidateByResourceID(c.Request.Context(), sourceFlow.ID) _ = h.cacheService.InvalidateByResourceID(c.Request.Context(), targetFlow.ID) c.JSON(http.StatusCreated, match) } // UpdateMatchStatus updates the status of a match // PUT /api/matching/:matchId/status func (h *MatchingHandler) UpdateMatchStatus(c *gin.Context) { matchID := c.Param("matchId") var req struct { Status domain.MatchStatus `json:"status" binding:"required"` Actor string `json:"actor" binding:"required"` Notes string `json:"notes"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.matchingService.UpdateMatchStatus(c.Request.Context(), matchID, req.Status, req.Actor, req.Notes); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Match status updated successfully"}) } // GetTopMatches retrieves top matches by score // GET /api/matching/top func (h *MatchingHandler) GetTopMatches(c *gin.Context) { // Default limit of 10, max 100 limit := 10 if limitParam := c.Query("limit"); limitParam != "" { if parsedLimit, err := strconv.Atoi(limitParam); err == nil && parsedLimit > 0 && parsedLimit <= 100 { limit = parsedLimit } } matches, err := h.matchingService.GetTopMatches(c.Request.Context(), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve top matches"}) return } c.JSON(http.StatusOK, matches) }