package handler import ( "encoding/json" "net/http" "strconv" "time" "bugulma/backend/internal/domain" "bugulma/backend/internal/geospatial" "bugulma/backend/internal/matching" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/lib/pq" "gorm.io/datatypes" ) type DiscoveryHandler struct { matchingService *matching.Service } func NewDiscoveryHandler(matchingService *matching.Service) *DiscoveryHandler { return &DiscoveryHandler{ matchingService: matchingService, } } // SearchRequest represents a discovery search query type SearchRequest struct { Query string `json:"query" form:"query"` Categories []string `json:"categories" form:"categories"` Latitude *float64 `json:"latitude" form:"latitude"` Longitude *float64 `json:"longitude" form:"longitude"` RadiusKm float64 `json:"radius_km" form:"radius_km"` MaxPrice *float64 `json:"max_price" form:"max_price"` MinPrice *float64 `json:"min_price" form:"min_price"` AvailabilityStatus string `json:"availability_status" form:"availability_status"` Tags []string `json:"tags" form:"tags"` Limit int `json:"limit" form:"limit"` Offset int `json:"offset" form:"offset"` } // Convert SearchRequest to DiscoveryQuery func (req *SearchRequest) ToDiscoveryQuery() matching.DiscoveryQuery { query := matching.DiscoveryQuery{ Query: req.Query, Categories: req.Categories, RadiusKm: req.RadiusKm, MaxPrice: req.MaxPrice, MinPrice: req.MinPrice, AvailabilityStatus: req.AvailabilityStatus, Tags: req.Tags, Limit: req.Limit, Offset: req.Offset, } // Set default limit if not provided if query.Limit <= 0 { query.Limit = 20 } if query.Limit > 100 { query.Limit = 100 // Cap at 100 } // Set default radius if location provided but radius not set if req.Latitude != nil && req.Longitude != nil && query.RadiusKm <= 0 { query.RadiusKm = 50.0 // Default 50km radius } // Set location if provided if req.Latitude != nil && req.Longitude != nil { query.Location = &geospatial.Point{ Latitude: *req.Latitude, Longitude: *req.Longitude, } } return query } // UniversalSearch performs a unified search across all discovery types // GET /api/v1/discovery/search func (h *DiscoveryHandler) UniversalSearch(c *gin.Context) { var req SearchRequest if err := c.ShouldBindQuery(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()}) return } query := req.ToDiscoveryQuery() result, err := h.matchingService.UniversalSearch(c.Request.Context(), query) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to perform search", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "query": result.Query, "product_matches": result.ProductMatches, "service_matches": result.ServiceMatches, "community_matches": result.CommunityMatches, "total": len(result.ProductMatches) + len(result.ServiceMatches) + len(result.CommunityMatches), }) } // SearchProducts searches for products // GET /api/v1/discovery/products func (h *DiscoveryHandler) SearchProducts(c *gin.Context) { var req SearchRequest if err := c.ShouldBindQuery(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()}) return } query := req.ToDiscoveryQuery() matches, err := h.matchingService.FindProductMatches(c.Request.Context(), query) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search products", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "matches": matches, "total": len(matches), }) } // SearchServices searches for services // GET /api/v1/discovery/services func (h *DiscoveryHandler) SearchServices(c *gin.Context) { var req SearchRequest if err := c.ShouldBindQuery(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()}) return } query := req.ToDiscoveryQuery() matches, err := h.matchingService.FindServiceMatches(c.Request.Context(), query) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search services", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "matches": matches, "total": len(matches), }) } // SearchCommunity searches for community listings // GET /api/v1/discovery/community func (h *DiscoveryHandler) SearchCommunity(c *gin.Context) { var req SearchRequest if err := c.ShouldBindQuery(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()}) return } query := req.ToDiscoveryQuery() // Use UniversalSearch and extract community matches result, err := h.matchingService.UniversalSearch(c.Request.Context(), query) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search community listings", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "matches": result.CommunityMatches, "total": len(result.CommunityMatches), }) } // CreateProductRequest represents a product creation request type CreateProductRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` Category string `json:"category" binding:"required"` UnitPrice float64 `json:"unit_price" binding:"required"` MOQ int `json:"moq"` OrganizationID string `json:"organization_id" binding:"required"` SiteID *string `json:"site_id"` // Optional: link to site SearchKeywords string `json:"search_keywords"` Tags []string `json:"tags"` AvailabilityStatus string `json:"availability_status"` Images []string `json:"images"` // Location can be provided directly or derived from SiteID Latitude *float64 `json:"latitude"` Longitude *float64 `json:"longitude"` } // CreateProductListing creates a new product listing (business) // POST /api/v1/discovery/products func (h *DiscoveryHandler) CreateProductListing(c *gin.Context) { var req CreateProductRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) return } // Create product domain object product := &domain.Product{ ID: uuid.New().String(), Name: req.Name, Description: req.Description, Category: domain.ProductCategory(req.Category), UnitPrice: req.UnitPrice, MOQ: req.MOQ, OrganizationID: req.OrganizationID, SiteID: req.SiteID, SearchKeywords: req.SearchKeywords, Tags: pq.StringArray(req.Tags), AvailabilityStatus: req.AvailabilityStatus, Images: pq.StringArray(req.Images), CreatedAt: time.Now(), UpdatedAt: time.Now(), } // Set location if provided directly if req.Latitude != nil && req.Longitude != nil { product.Location = domain.Point{ Latitude: *req.Latitude, Longitude: *req.Longitude, Valid: true, } } // Create product (matching service will handle SiteID -> location mapping) if err := h.matchingService.CreateProduct(c.Request.Context(), product); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create product", "details": err.Error()}) return } c.JSON(http.StatusCreated, product) } // CreateServiceRequest represents a service creation request type CreateServiceRequest struct { Type string `json:"type" binding:"required"` Domain string `json:"domain" binding:"required"` Description string `json:"description"` HourlyRate float64 `json:"hourly_rate"` ServiceAreaKm float64 `json:"service_area_km"` OrganizationID string `json:"organization_id" binding:"required"` SiteID *string `json:"site_id"` // Optional: link to site SearchKeywords string `json:"search_keywords"` Tags []string `json:"tags"` AvailabilityStatus string `json:"availability_status"` AvailabilitySchedule *string `json:"availability_schedule"` // Location can be provided directly or derived from SiteID Latitude *float64 `json:"latitude"` Longitude *float64 `json:"longitude"` } // CreateServiceListing creates a new service listing (business) // POST /api/v1/discovery/services func (h *DiscoveryHandler) CreateServiceListing(c *gin.Context) { var req CreateServiceRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) return } // Create service domain object service := &domain.Service{ ID: uuid.New().String(), Type: domain.ServiceType(req.Type), Domain: req.Domain, Description: req.Description, HourlyRate: req.HourlyRate, ServiceAreaKm: req.ServiceAreaKm, OrganizationID: req.OrganizationID, SiteID: req.SiteID, SearchKeywords: req.SearchKeywords, Tags: pq.StringArray(req.Tags), AvailabilityStatus: req.AvailabilityStatus, CreatedAt: time.Now(), UpdatedAt: time.Now(), } // Set availability schedule if provided if req.AvailabilitySchedule != nil { scheduleJSON, _ := json.Marshal(*req.AvailabilitySchedule) service.AvailabilitySchedule = datatypes.JSON(scheduleJSON) } // Set location if provided directly if req.Latitude != nil && req.Longitude != nil { service.ServiceLocation = domain.Point{ Latitude: *req.Latitude, Longitude: *req.Longitude, Valid: true, } } // Create service (matching service will handle SiteID -> location mapping) if err := h.matchingService.CreateService(c.Request.Context(), service); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service", "details": err.Error()}) return } c.JSON(http.StatusCreated, service) } // CreateCommunityListingRequest represents a community listing creation request type CreateCommunityListingRequest struct { Title string `json:"title" binding:"required"` Description string `json:"description"` ListingType string `json:"listing_type" binding:"required,oneof=product service tool skill need"` Category string `json:"category" binding:"required"` Subcategory *string `json:"subcategory"` // For Products/Tools Condition *string `json:"condition,omitempty"` Price *float64 `json:"price,omitempty"` PriceType *string `json:"price_type,omitempty"` Quantity *int `json:"quantity,omitempty"` // For Services/Skills ServiceType *string `json:"service_type,omitempty"` Rate *float64 `json:"rate,omitempty"` RateType *string `json:"rate_type,omitempty"` // Location Latitude *float64 `json:"latitude,omitempty"` Longitude *float64 `json:"longitude,omitempty"` // Availability PickupAvailable *bool `json:"pickup_available,omitempty"` DeliveryAvailable *bool `json:"delivery_available,omitempty"` DeliveryRadiusKm *float64 `json:"delivery_radius_km,omitempty"` // Media Images []string `json:"images,omitempty"` // Metadata Tags []string `json:"tags,omitempty"` } // CreateCommunityListing creates a new community listing (user) // POST /api/v1/discovery/community func (h *DiscoveryHandler) CreateCommunityListing(c *gin.Context) { var req CreateCommunityListingRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) return } // Get user ID from context (set by auth middleware) userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } userIDStr, ok := userID.(string) if !ok { c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user ID"}) return } // Create community listing domain object listing := &domain.CommunityListing{ ID: uuid.New().String(), UserID: userIDStr, Title: req.Title, Description: req.Description, ListingType: domain.CommunityListingType(req.ListingType), Category: req.Category, CreatedAt: time.Now(), UpdatedAt: time.Now(), } // Set optional fields if req.Subcategory != nil { listing.Subcategory = *req.Subcategory } // Product/Tool specific fields if req.Condition != nil { condition := domain.CommunityListingCondition(*req.Condition) listing.Condition = &condition } if req.Price != nil { listing.Price = req.Price } if req.PriceType != nil { listing.PriceType = (*domain.CommunityListingPriceType)(req.PriceType) } if req.Quantity != nil { listing.QuantityAvailable = req.Quantity } // Service/Skill specific fields if req.ServiceType != nil { listing.ServiceType = req.ServiceType } if req.Rate != nil { listing.Rate = req.Rate } if req.RateType != nil { listing.RateType = req.RateType } // Location if req.Latitude != nil && req.Longitude != nil { listing.Location = domain.Point{ Latitude: *req.Latitude, Longitude: *req.Longitude, Valid: true, } } // Availability settings if req.PickupAvailable != nil { listing.PickupAvailable = *req.PickupAvailable } else { listing.PickupAvailable = true // default } if req.DeliveryAvailable != nil { listing.DeliveryAvailable = *req.DeliveryAvailable } if req.DeliveryRadiusKm != nil { listing.DeliveryRadiusKm = req.DeliveryRadiusKm } // Media if len(req.Images) > 0 { listing.Images = pq.StringArray(req.Images) } // Metadata if len(req.Tags) > 0 { listing.Tags = pq.StringArray(req.Tags) } // Create the listing if err := h.matchingService.CreateCommunityListing(c.Request.Context(), listing); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create community listing", "details": err.Error()}) return } c.JSON(http.StatusCreated, listing) } // Helper function to parse query parameters func parseSearchQuery(c *gin.Context) SearchRequest { req := SearchRequest{} if query := c.Query("query"); query != "" { req.Query = query } if categories := c.QueryArray("categories"); len(categories) > 0 { req.Categories = categories } if latStr := c.Query("latitude"); latStr != "" { if lat, err := strconv.ParseFloat(latStr, 64); err == nil { req.Latitude = &lat } } if lngStr := c.Query("longitude"); lngStr != "" { if lng, err := strconv.ParseFloat(lngStr, 64); err == nil { req.Longitude = &lng } } if radiusStr := c.Query("radius_km"); radiusStr != "" { if radius, err := strconv.ParseFloat(radiusStr, 64); err == nil { req.RadiusKm = radius } } if maxPriceStr := c.Query("max_price"); maxPriceStr != "" { if maxPrice, err := strconv.ParseFloat(maxPriceStr, 64); err == nil { req.MaxPrice = &maxPrice } } if minPriceStr := c.Query("min_price"); minPriceStr != "" { if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil { req.MinPrice = &minPrice } } if availabilityStatus := c.Query("availability_status"); availabilityStatus != "" { req.AvailabilityStatus = availabilityStatus } if tags := c.QueryArray("tags"); len(tags) > 0 { req.Tags = tags } if limitStr := c.Query("limit"); limitStr != "" { if limit, err := strconv.Atoi(limitStr); err == nil { req.Limit = limit } } if offsetStr := c.Query("offset"); offsetStr != "" { if offset, err := strconv.Atoi(offsetStr); err == nil { req.Offset = offset } } return req } // GetCategories returns available categories for discovery search // GET /api/v1/discovery/categories func (h *DiscoveryHandler) GetCategories(c *gin.Context) { // Product categories from enum productCategories := []string{ "chemicals", "equipment", "materials", "food", "packaging", "oil_gas", "construction", "manufacturing", "other", } // Service types from enum serviceCategories := []string{ "maintenance", "consulting", "transport", "inspection", "training", "repair", "other", } // For community listings, we'll return common categories // In the future, this could query unique categories from the database communityCategories := []string{ "materials", "equipment", "tools", "food", "textiles", "electronics", "furniture", "transportation", "consulting", "education", "labor", "other", } c.JSON(http.StatusOK, gin.H{ "products": productCategories, "services": serviceCategories, "community": communityCategories, }) }