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
- Phase 2: Enhanced search with relevance scoring
- Phase 3: User dashboard and listing management
- Phase 4: Trust system (ratings, reviews)
- Phase 5: Advanced features (booking, matching)
Last Updated: 2025-01-27 Status: Implementation Guide