mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
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)
343 lines
10 KiB
Go
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)
|
|
}
|