# 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` ```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` ```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` ```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` ```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` ```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` ```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: ```go 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` ```typescript 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 = ({ onSearch }) => { const [query, setQuery] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSearch(query); }; return (
setQuery(e.target.value)} className="flex-1" />
); }; ``` --- ### Step 8: Frontend - Results Page Create: `bugulma/frontend/pages/DiscoveryPage.tsx` ```typescript 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 (

Find Products & Services

{loading &&
Searching...
} {results && }
); }; ``` --- ### Step 9: Frontend - API Service Create: `bugulma/frontend/services/discovery-api.ts` ```typescript 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 { 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(`/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