turash/bugulma/backend/internal/handler/matching_handler.go
Damir Mukimov 000eab4740
Major repository reorganization and missing backend endpoints implementation
Repository Structure:
- Move files from cluttered root directory into organized structure
- Create archive/ for archived data and scraper results
- Create bugulma/ for the complete application (frontend + backend)
- Create data/ for sample datasets and reference materials
- Create docs/ for comprehensive documentation structure
- Create scripts/ for utility scripts and API tools

Backend Implementation:
- Implement 3 missing backend endpoints identified in gap analysis:
  * GET /api/v1/organizations/{id}/matching/direct - Direct symbiosis matches
  * GET /api/v1/users/me/organizations - User organizations
  * POST /api/v1/proposals/{id}/status - Update proposal status
- Add complete proposal domain model, repository, and service layers
- Create database migration for proposals table
- Fix CLI server command registration issue

API Documentation:
- Add comprehensive proposals.md API documentation
- Update README.md with Users and Proposals API sections
- Document all request/response formats, error codes, and business rules

Code Quality:
- Follow existing Go backend architecture patterns
- Add proper error handling and validation
- Match frontend expected response schemas
- Maintain clean separation of concerns (handler -> service -> repository)
2025-11-25 06:01:16 +01:00

343 lines
10 KiB
Go

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)
}