turash/PRODUCT_SERVICE_DISCOVERY_IMPLEMENTATION.md

19 KiB

Product & Service Discovery: Quick Start Implementation Guide

Overview

This guide provides step-by-step instructions to implement the first phase of the Product & Service Discovery feature.


Phase 1: Foundation (Weeks 1-3)

Step 1: Database Migrations

Create migration file: bugulma/backend/migrations/postgres/019_create_discovery_tables.up.sql

-- +migrate Up
-- Product Listings (Business Products)
CREATE TABLE product_listings (
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
  organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE,
  site_id TEXT REFERENCES sites(id),

  name VARCHAR(255) NOT NULL,
  description TEXT,
  category VARCHAR(100) NOT NULL,
  subcategory VARCHAR(100),
  brand VARCHAR(100),
  model VARCHAR(100),

  price DECIMAL(10,2),
  price_unit VARCHAR(50),
  currency VARCHAR(3) DEFAULT 'EUR',
  negotiable BOOLEAN DEFAULT false,

  quantity_available INTEGER,
  availability_status VARCHAR(20) DEFAULT 'available',
  availability_schedule JSONB,

  pickup_location POINT,
  delivery_available BOOLEAN DEFAULT false,
  delivery_radius_km DECIMAL(5,2),
  delivery_cost DECIMAL(10,2),

  images TEXT[],
  specifications JSONB,
  tags TEXT[],
  search_keywords TEXT,
  verified BOOLEAN DEFAULT false,

  status VARCHAR(20) DEFAULT 'active',
  featured BOOLEAN DEFAULT false,

  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  expires_at TIMESTAMP WITH TIME ZONE
);

-- Service Listings (Business Services)
CREATE TABLE service_listings (
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
  organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE,
  site_id TEXT REFERENCES sites(id),

  name VARCHAR(255) NOT NULL,
  description TEXT,
  category VARCHAR(100) NOT NULL,
  subcategory VARCHAR(100),

  price DECIMAL(10,2),
  price_type VARCHAR(50),
  currency VARCHAR(3) DEFAULT 'EUR',
  negotiable BOOLEAN DEFAULT false,

  availability_status VARCHAR(20) DEFAULT 'available',
  availability_schedule JSONB,
  booking_required BOOLEAN DEFAULT false,
  min_advance_booking_hours INTEGER,

  service_location POINT,
  service_radius_km DECIMAL(5,2),
  on_site_service BOOLEAN DEFAULT true,
  remote_service BOOLEAN DEFAULT false,

  requirements TEXT,
  duration_estimate VARCHAR(100),

  images TEXT[],
  portfolio_urls TEXT[],
  tags TEXT[],
  search_keywords TEXT,
  certifications TEXT[],
  verified BOOLEAN DEFAULT false,

  status VARCHAR(20) DEFAULT 'active',
  featured BOOLEAN DEFAULT false,

  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Community Listings
CREATE TABLE community_listings (
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,

  title VARCHAR(255) NOT NULL,
  description TEXT,
  listing_type VARCHAR(50) NOT NULL,
  category VARCHAR(100) NOT NULL,
  subcategory VARCHAR(100),

  condition VARCHAR(50),
  price DECIMAL(10,2),
  price_type VARCHAR(50),

  service_type VARCHAR(50),
  rate DECIMAL(10,2),
  rate_type VARCHAR(50),

  availability_status VARCHAR(20) DEFAULT 'available',
  availability_schedule JSONB,
  quantity_available INTEGER,

  location POINT,
  pickup_available BOOLEAN DEFAULT true,
  delivery_available BOOLEAN DEFAULT false,
  delivery_radius_km DECIMAL(5,2),

  images TEXT[],
  tags TEXT[],
  search_keywords TEXT,

  user_rating DECIMAL(3,2),
  review_count INTEGER DEFAULT 0,
  verified BOOLEAN DEFAULT false,

  status VARCHAR(20) DEFAULT 'active',

  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  expires_at TIMESTAMP WITH TIME ZONE
);

-- Indexes
CREATE INDEX idx_product_listings_org ON product_listings(organization_id);
CREATE INDEX idx_product_listings_category ON product_listings(category);
CREATE INDEX idx_product_listings_status ON product_listings(status);
CREATE INDEX idx_product_listings_location ON product_listings USING GIST(pickup_location);
CREATE INDEX idx_product_listings_search ON product_listings USING GIN(to_tsvector('russian', name || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')));

CREATE INDEX idx_service_listings_org ON service_listings(organization_id);
CREATE INDEX idx_service_listings_category ON service_listings(category);
CREATE INDEX idx_service_listings_status ON service_listings(status);
CREATE INDEX idx_service_listings_location ON service_listings USING GIST(service_location);
CREATE INDEX idx_service_listings_search ON service_listings USING GIN(to_tsvector('russian', name || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')));

CREATE INDEX idx_community_listings_user ON community_listings(user_id);
CREATE INDEX idx_community_listings_type ON community_listings(listing_type);
CREATE INDEX idx_community_listings_category ON community_listings(category);
CREATE INDEX idx_community_listings_status ON community_listings(status);
CREATE INDEX idx_community_listings_location ON community_listings USING GIST(location);
CREATE INDEX idx_community_listings_search ON community_listings USING GIN(to_tsvector('russian', title || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')));

Create down migration: 019_create_discovery_tables.down.sql

-- +migrate Down
DROP TABLE IF EXISTS community_listings;
DROP TABLE IF EXISTS service_listings;
DROP TABLE IF EXISTS product_listings;

Step 2: Domain Models

Create: bugulma/backend/internal/domain/product_listing.go

package domain

import (
	"time"
	"github.com/lib/pq"
)

type ProductListing struct {
	ID                 string         `gorm:"primaryKey;type:text"`
	OrganizationID     string         `gorm:"type:text;index"`
	SiteID             *string        `gorm:"type:text"`

	Name               string         `gorm:"type:varchar(255);not null"`
	Description        *string        `gorm:"type:text"`
	Category           string         `gorm:"type:varchar(100);not null;index"`
	Subcategory        *string        `gorm:"type:varchar(100)"`
	Brand              *string        `gorm:"type:varchar(100)"`
	Model              *string        `gorm:"type:varchar(100)"`

	Price              *float64       `gorm:"type:decimal(10,2)"`
	PriceUnit          *string        `gorm:"type:varchar(50)"`
	Currency           string         `gorm:"type:varchar(3);default:'EUR'"`
	Negotiable         bool           `gorm:"default:false"`

	QuantityAvailable  *int
	AvailabilityStatus string         `gorm:"type:varchar(20);default:'available'"`
	AvailabilitySchedule *string      `gorm:"type:jsonb"`

	PickupLocation     *string        `gorm:"type:point"` // PostGIS point
	DeliveryAvailable  bool           `gorm:"default:false"`
	DeliveryRadiusKm   *float64       `gorm:"type:decimal(5,2)"`
	DeliveryCost       *float64       `gorm:"type:decimal(10,2)"`

	Images             pq.StringArray `gorm:"type:text[]"`
	Specifications     *string        `gorm:"type:jsonb"`
	Tags               pq.StringArray `gorm:"type:text[]"`
	SearchKeywords     *string        `gorm:"type:text"`
	Verified           bool           `gorm:"default:false"`

	Status             string         `gorm:"type:varchar(20);default:'active';index"`
	Featured           bool           `gorm:"default:false"`

	CreatedAt          time.Time      `gorm:"autoCreateTime"`
	UpdatedAt          time.Time      `gorm:"autoUpdateTime"`
	ExpiresAt          *time.Time

	// Relationships
	Organization       *Organization  `gorm:"foreignKey:OrganizationID"`
	Site               *Site          `gorm:"foreignKey:SiteID"`
}

Create similar models for ServiceListing and CommunityListing.


Step 3: Repository Layer

Create: bugulma/backend/internal/repository/product_listing_repository.go

package repository

import (
	"context"
	"bugulma/backend/internal/domain"
	"gorm.io/gorm"
)

type ProductListingRepository interface {
	Create(ctx context.Context, listing *domain.ProductListing) error
	GetByID(ctx context.Context, id string) (*domain.ProductListing, error)
	Update(ctx context.Context, listing *domain.ProductListing) error
	Delete(ctx context.Context, id string) error
	Search(ctx context.Context, query SearchQuery) ([]*domain.ProductListing, int64, error)
	GetByOrganization(ctx context.Context, orgID string) ([]*domain.ProductListing, error)
}

type productListingRepository struct {
	db *gorm.DB
}

func NewProductListingRepository(db *gorm.DB) ProductListingRepository {
	return &productListingRepository{db: db}
}

func (r *productListingRepository) Create(ctx context.Context, listing *domain.ProductListing) error {
	return r.db.WithContext(ctx).Create(listing).Error
}

func (r *productListingRepository) GetByID(ctx context.Context, id string) (*domain.ProductListing, error) {
	var listing domain.ProductListing
	err := r.db.WithContext(ctx).
		Preload("Organization").
		Preload("Site").
		First(&listing, "id = ?", id).Error
	if err != nil {
		return nil, err
	}
	return &listing, nil
}

func (r *productListingRepository) Search(ctx context.Context, query SearchQuery) ([]*domain.ProductListing, int64, error) {
	var listings []*domain.ProductListing
	var total int64

	db := r.db.WithContext(ctx).Model(&domain.ProductListing{}).
		Where("status = ?", "active")

	// Text search
	if query.Text != "" {
		db = db.Where(
			"to_tsvector('russian', name || ' ' || COALESCE(description, '') || ' ' || COALESCE(search_keywords, '')) @@ plainto_tsquery('russian', ?)",
			query.Text,
		)
	}

	// Category filter
	if query.Category != "" {
		db = db.Where("category = ?", query.Category)
	}

	// Location filter (if provided)
	if query.Location != nil {
		// Use PostGIS ST_DWithin for distance filtering
		db = db.Where(
			"ST_DWithin(pickup_location::geography, ST_MakePoint(?, ?)::geography, ?)",
			query.Location.Lng,
			query.Location.Lat,
			query.MaxDistanceKm*1000, // Convert km to meters
		)
	}

	// Price filter
	if query.PriceMin != nil {
		db = db.Where("price >= ?", *query.PriceMin)
	}
	if query.PriceMax != nil {
		db = db.Where("price <= ?", *query.PriceMax)
	}

	// Count total
	if err := db.Count(&total).Error; err != nil {
		return nil, 0, err
	}

	// Apply pagination and ordering
	offset := (query.Page - 1) * query.Limit
	err := db.
		Preload("Organization").
		Offset(offset).
		Limit(query.Limit).
		Order("featured DESC, created_at DESC").
		Find(&listings).Error

	return listings, total, err
}

type SearchQuery struct {
	Text         string
	Category     string
	Location     *struct {
		Lat float64
		Lng float64
	}
	MaxDistanceKm float64
	PriceMin      *float64
	PriceMax      *float64
	Page          int
	Limit         int
}

Step 4: Service Layer

Create: bugulma/backend/internal/service/discovery_service.go

package service

import (
	"context"
	"bugulma/backend/internal/domain"
	"bugulma/backend/internal/repository"
)

type DiscoveryService interface {
	SearchProducts(ctx context.Context, query SearchQuery) (*SearchResults, error)
	SearchServices(ctx context.Context, query SearchQuery) (*SearchResults, error)
	SearchCommunity(ctx context.Context, query SearchQuery) (*SearchResults, error)
	UniversalSearch(ctx context.Context, query SearchQuery) (*UniversalSearchResults, error)
}

type discoveryService struct {
	productRepo  repository.ProductListingRepository
	serviceRepo  repository.ServiceListingRepository
	communityRepo repository.CommunityListingRepository
}

func NewDiscoveryService(
	productRepo repository.ProductListingRepository,
	serviceRepo repository.ServiceListingRepository,
	communityRepo repository.CommunityListingRepository,
) DiscoveryService {
	return &discoveryService{
		productRepo:  productRepo,
		serviceRepo:  serviceRepo,
		communityRepo: communityRepo,
	}
}

func (s *discoveryService) UniversalSearch(ctx context.Context, query SearchQuery) (*UniversalSearchResults, error) {
	// Search all types in parallel
	products, productsTotal, _ := s.productRepo.Search(ctx, query)
	services, servicesTotal, _ := s.serviceRepo.Search(ctx, query)
	community, communityTotal, _ := s.communityRepo.Search(ctx, query)

	// Combine and rank results
	results := &UniversalSearchResults{
		Products:  products,
		Services:   services,
		Community: community,
		Total:      productsTotal + servicesTotal + communityTotal,
	}

	return results, nil
}

Step 5: Handler Layer

Create: bugulma/backend/internal/handler/discovery_handler.go

package handler

import (
	"net/http"
	"bugulma/backend/internal/service"
	"github.com/gin-gonic/gin"
)

type DiscoveryHandler struct {
	discoveryService service.DiscoveryService
}

func NewDiscoveryHandler(discoveryService service.DiscoveryService) *DiscoveryHandler {
	return &DiscoveryHandler{
		discoveryService: discoveryService,
	}
}

// GET /api/v1/discovery/search
func (h *DiscoveryHandler) Search(c *gin.Context) {
	var query service.SearchQuery
	if err := c.ShouldBindQuery(&query); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// Set defaults
	if query.Page == 0 {
		query.Page = 1
	}
	if query.Limit == 0 {
		query.Limit = 20
	}
	if query.MaxDistanceKm == 0 {
		query.MaxDistanceKm = 25.0
	}

	results, err := h.discoveryService.UniversalSearch(c.Request.Context(), query)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, results)
}

Step 6: Routes

Update: bugulma/backend/internal/routes/routes.go

Add discovery routes:

func RegisterDiscoveryRoutes(public, protected *gin.RouterGroup, discoveryHandler *handler.DiscoveryHandler) {
	discovery := public.Group("/discovery")
	{
		discovery.GET("/search", discoveryHandler.Search)
		discovery.GET("/products", discoveryHandler.GetProducts)
		discovery.GET("/services", discoveryHandler.GetServices)
		discovery.GET("/community", discoveryHandler.GetCommunity)
		discovery.GET("/listings/:type/:id", discoveryHandler.GetListing)
	}

	discoveryProtected := protected.Group("/discovery")
	{
		discoveryProtected.POST("/products", discoveryHandler.CreateProduct)
		discoveryProtected.POST("/services", discoveryHandler.CreateService)
		discoveryProtected.POST("/community", discoveryHandler.CreateCommunity)
		discoveryProtected.PUT("/listings/:type/:id", discoveryHandler.UpdateListing)
		discoveryProtected.DELETE("/listings/:type/:id", discoveryHandler.DeleteListing)
	}
}

Step 7: Frontend - Search Component

Create: bugulma/frontend/components/discovery/DiscoverySearchBar.tsx

import React, { useState } from 'react';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

interface DiscoverySearchBarProps {
  onSearch: (query: string) => void;
}

export const DiscoverySearchBar: React.FC<DiscoverySearchBarProps> = ({ onSearch }) => {
  const [query, setQuery] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSearch(query);
  };

  return (
    <form onSubmit={handleSubmit} className="w-full max-w-2xl mx-auto">
      <div className="flex gap-2">
        <Input
          type="text"
          placeholder="I need a van, who fixes computers, looking for..."
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          className="flex-1"
        />
        <Button type="submit">
          <Search className="w-4 h-4 mr-2" />
          Search
        </Button>
      </div>
    </form>
  );
};

Step 8: Frontend - Results Page

Create: bugulma/frontend/pages/DiscoveryPage.tsx

import React, { useState, useEffect } from 'react';
import { DiscoverySearchBar } from '@/components/discovery/DiscoverySearchBar';
import { DiscoveryResults } from '@/components/discovery/DiscoveryResults';
import { discoveryAPI } from '@/services/discovery-api';

export const DiscoveryPage: React.FC = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(null);
  const [loading, setLoading] = useState(false);

  const handleSearch = async (searchQuery: string) => {
    setQuery(searchQuery);
    setLoading(true);

    try {
      const data = await discoveryAPI.search({
        q: searchQuery,
        page: 1,
        limit: 20,
      });
      setResults(data);
    } catch (error) {
      console.error('Search error:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-8">Find Products & Services</h1>
      <DiscoverySearchBar onSearch={handleSearch} />

      {loading && <div>Searching...</div>}
      {results && <DiscoveryResults results={results} />}
    </div>
  );
};

Step 9: Frontend - API Service

Create: bugulma/frontend/services/discovery-api.ts

import { BaseService } from './base-service';

export interface SearchQuery {
  q?: string;
  category?: string;
  listing_type?: string;
  location?: { lat: number; lng: number };
  max_distance_km?: number;
  price_min?: number;
  price_max?: number;
  page?: number;
  limit?: number;
}

export interface SearchResult {
  id: string;
  type: 'product' | 'service' | 'community';
  title: string;
  description: string;
  category: string;
  price?: number;
  location: { lat: number; lng: number };
  distance_km?: number;
  organization_name?: string;
  user_name?: string;
  rating?: number;
  verified: boolean;
  images: string[];
}

export interface SearchResponse {
  results: SearchResult[];
  total: number;
  page: number;
  limit: number;
}

class DiscoveryService extends BaseService {
  constructor() {
    super('/api/v1/discovery');
  }

  async search(query: SearchQuery): Promise<SearchResponse> {
    const params = new URLSearchParams();
    if (query.q) params.append('q', query.q);
    if (query.category) params.append('category', query.category);
    if (query.location) {
      params.append('lat', query.location.lat.toString());
      params.append('lng', query.location.lng.toString());
    }
    if (query.max_distance_km) params.append('max_distance_km', query.max_distance_km.toString());
    if (query.page) params.append('page', query.page.toString());
    if (query.limit) params.append('limit', query.limit.toString());

    return this.get<SearchResponse>(`/search?${params.toString()}`);
  }
}

export const discoveryAPI = new DiscoveryService();

Testing Checklist

  • Database migrations run successfully
  • Can create product listing (business)
  • Can create service listing (business)
  • Can create community listing (user)
  • Search returns results
  • Location filtering works
  • Category filtering works
  • Text search works
  • Frontend search bar works
  • Results display correctly
  • Listing detail page works

Next Steps After Phase 1

  1. Phase 2: Enhanced search with relevance scoring
  2. Phase 3: User dashboard and listing management
  3. Phase 4: Trust system (ratings, reviews)
  4. Phase 5: Advanced features (booking, matching)

Last Updated: 2025-01-27 Status: Implementation Guide