turash/bugulma/backend/internal/handler/discovery_handler.go

562 lines
16 KiB
Go

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