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