Merge pull request #21 from rede5/codex/implement-advanced-search-repository-layer

Add marketplace advanced record search with windowed pagination and updated_at trigger
This commit is contained in:
Tiago Yamamoto 2025-12-21 17:36:28 -03:00 committed by GitHub
commit d63fb0da2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 261 additions and 24 deletions

View file

@ -0,0 +1,33 @@
package domain
import "time"
// SearchRequest represents an API-level request for advanced listings.
type SearchRequest struct {
Query string `json:"query"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
CreatedAfter *time.Time `json:"created_after,omitempty"`
CreatedBefore *time.Time `json:"created_before,omitempty"`
}
// RecordSearchFilter contains normalized filtering inputs for repositories.
type RecordSearchFilter struct {
Query string
SortBy string
SortOrder string
CreatedAfter *time.Time
CreatedBefore *time.Time
Limit int
Offset int
}
// PaginationResponse represents a paginated response.
type PaginationResponse[T any] struct {
Items []T `json:"items"`
TotalCount int64 `json:"total_count"`
CurrentPage int `json:"current_page"`
TotalPages int `json:"total_pages"`
}

View file

@ -91,6 +91,10 @@ func (m *MockRepository) ListProducts(ctx context.Context, filter domain.Product
return m.products, int64(len(m.products)), nil
}
func (m *MockRepository) ListRecords(ctx context.Context, filter domain.RecordSearchFilter) ([]domain.Product, int64, error) {
return m.products, int64(len(m.products)), nil
}
func (m *MockRepository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
for _, p := range m.products {
if p.ID == id {

View file

@ -0,0 +1,58 @@
package handler
import (
"net/http"
"time"
"github.com/saveinmed/backend-go/internal/domain"
)
// ListMarketplaceRecords godoc
// @Summary Busca avançada no marketplace
// @Tags Marketplace
// @Produce json
// @Param query query string false "Busca textual"
// @Param sort_by query string false "Campo de ordenação (created_at|updated_at)"
// @Param sort_order query string false "Direção (asc|desc)"
// @Param created_after query string false "Data mínima (RFC3339)"
// @Param created_before query string false "Data máxima (RFC3339)"
// @Param page query integer false "Página"
// @Param page_size query integer false "Itens por página"
// @Success 200 {object} domain.PaginationResponse[domain.Product]
// @Router /api/v1/marketplace/records [get]
func (h *Handler) ListMarketplaceRecords(w http.ResponseWriter, r *http.Request) {
page, pageSize := parsePagination(r)
req := domain.SearchRequest{
Query: r.URL.Query().Get("query"),
Page: page,
PageSize: pageSize,
SortBy: r.URL.Query().Get("sort_by"),
SortOrder: r.URL.Query().Get("sort_order"),
}
if v := r.URL.Query().Get("created_after"); v != "" {
createdAfter, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
req.CreatedAfter = &createdAfter
}
if v := r.URL.Query().Get("created_before"); v != "" {
createdBefore, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
req.CreatedBefore = &createdBefore
}
result, err := h.svc.ListRecords(r.Context(), req)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, result)
}

View file

@ -0,0 +1,17 @@
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
ALTER TABLE products
ALTER COLUMN created_at SET DEFAULT NOW(),
ALTER COLUMN updated_at SET DEFAULT NOW();
DROP TRIGGER IF EXISTS set_products_updated_at ON products;
CREATE TRIGGER set_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

View file

@ -158,15 +158,22 @@ func (r *Repository) DeleteCompany(ctx context.Context, id uuid.UUID) error {
}
func (r *Repository) CreateProduct(ctx context.Context, product *domain.Product) error {
now := time.Now().UTC()
product.CreatedAt = now
product.UpdatedAt = now
query := `INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock)
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock)
RETURNING created_at, updated_at`
query := `INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)`
rows, err := r.db.NamedQueryContext(ctx, query, product)
if err != nil {
return err
}
defer rows.Close()
_, err := r.db.NamedExecContext(ctx, query, product)
return err
if rows.Next() {
if err := rows.Scan(&product.CreatedAt, &product.UpdatedAt); err != nil {
return err
}
}
return rows.Err()
}
func (r *Repository) ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, int64, error) {
@ -206,6 +213,73 @@ func (r *Repository) ListProducts(ctx context.Context, filter domain.ProductFilt
return products, total, nil
}
// ListRecords returns marketplace listings using a window function for total count.
func (r *Repository) ListRecords(ctx context.Context, filter domain.RecordSearchFilter) ([]domain.Product, int64, error) {
baseQuery := `FROM products`
var args []any
var clauses []string
if filter.Query != "" {
clauses = append(clauses, fmt.Sprintf("(name ILIKE $%d OR description ILIKE $%d)", len(args)+1, len(args)+1))
args = append(args, "%"+filter.Query+"%")
}
if filter.CreatedAfter != nil {
clauses = append(clauses, fmt.Sprintf("created_at >= $%d", len(args)+1))
args = append(args, *filter.CreatedAfter)
}
if filter.CreatedBefore != nil {
clauses = append(clauses, fmt.Sprintf("created_at <= $%d", len(args)+1))
args = append(args, *filter.CreatedBefore)
}
where := ""
if len(clauses) > 0 {
where = " WHERE " + strings.Join(clauses, " AND ")
}
sortColumns := map[string]string{
"created_at": "created_at",
"updated_at": "updated_at",
}
sortBy := sortColumns[strings.ToLower(filter.SortBy)]
if sortBy == "" {
sortBy = "updated_at"
}
sortOrder := strings.ToUpper(filter.SortOrder)
if sortOrder != "ASC" {
sortOrder = "DESC"
}
if filter.Limit <= 0 {
filter.Limit = 20
}
args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf(`SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at,
COUNT(*) OVER() AS total_count
%s%s ORDER BY %s %s LIMIT $%d OFFSET $%d`, baseQuery, where, sortBy, sortOrder, len(args)-1, len(args))
type recordRow struct {
domain.Product
TotalCount int64 `db:"total_count"`
}
var rows []recordRow
if err := r.db.SelectContext(ctx, &rows, listQuery, args...); err != nil {
return nil, 0, err
}
total := int64(0)
if len(rows) > 0 {
total = rows[0].TotalCount
}
items := make([]domain.Product, 0, len(rows))
for _, row := range rows {
items = append(items, row.Product)
}
return items, total, nil
}
// SearchProducts returns products with distance from buyer, ordered by expiration date.
// Tenant info is anonymized (only city/state shown, not company name/ID).
func (r *Repository) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error) {
@ -306,24 +380,27 @@ func (r *Repository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Prod
}
func (r *Repository) UpdateProduct(ctx context.Context, product *domain.Product) error {
product.UpdatedAt = time.Now().UTC()
query := `UPDATE products
SET seller_id = :seller_id, name = :name, description = :description, batch = :batch, expires_at = :expires_at, price_cents = :price_cents, stock = :stock, updated_at = :updated_at
WHERE id = :id`
SET seller_id = :seller_id, name = :name, description = :description, batch = :batch, expires_at = :expires_at, price_cents = :price_cents, stock = :stock
WHERE id = :id
RETURNING updated_at`
res, err := r.db.NamedExecContext(ctx, query, product)
rows, err := r.db.NamedQueryContext(ctx, query, product)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return err
}
return errors.New("product not found")
}
return nil
if err := rows.Scan(&product.UpdatedAt); err != nil {
return err
}
return rows.Err()
}
func (r *Repository) DeleteProduct(ctx context.Context, id uuid.UUID) error {

View file

@ -100,12 +100,12 @@ func TestCreateProduct(t *testing.T) {
Batch: "B1",
PriceCents: 1000,
Stock: 10,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `INSERT INTO products`
mock.ExpectExec(regexp.QuoteMeta(query)).
rows := sqlmock.NewRows([]string{"created_at", "updated_at"}).
AddRow(time.Now(), time.Now())
mock.ExpectQuery(regexp.QuoteMeta(query)).
WithArgs(
product.ID,
product.SellerID,
@ -115,10 +115,8 @@ func TestCreateProduct(t *testing.T) {
product.ExpiresAt,
product.PriceCents,
product.Stock,
product.CreatedAt,
product.UpdatedAt,
).
WillReturnResult(sqlmock.NewResult(1, 1))
WillReturnRows(rows)
err := repo.CreateProduct(context.Background(), product)
assert.NoError(t, err)

View file

@ -75,6 +75,7 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("GET /api/v1/products", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/products/search", chain(http.HandlerFunc(h.SearchProducts), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/products/{id}", chain(http.HandlerFunc(h.GetProduct), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/marketplace/records", chain(http.HandlerFunc(h.ListMarketplaceRecords), middleware.Logger, middleware.Gzip))
mux.Handle("PATCH /api/v1/products/{id}", chain(http.HandlerFunc(h.UpdateProduct), middleware.Logger, middleware.Gzip))
mux.Handle("DELETE /api/v1/products/{id}", chain(http.HandlerFunc(h.DeleteProduct), middleware.Logger, middleware.Gzip))

View file

@ -26,6 +26,7 @@ type Repository interface {
CreateProduct(ctx context.Context, product *domain.Product) error
ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, int64, error)
SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error)
ListRecords(ctx context.Context, filter domain.RecordSearchFilter) ([]domain.Product, int64, error)
GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error)
UpdateProduct(ctx context.Context, product *domain.Product) error
DeleteProduct(ctx context.Context, id uuid.UUID) error
@ -248,6 +249,50 @@ func (s *Service) SearchProducts(ctx context.Context, filter domain.ProductSearc
return &domain.ProductSearchPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
}
// ListRecords provides an advanced search for marketplace listings.
func (s *Service) ListRecords(ctx context.Context, req domain.SearchRequest) (*domain.PaginationResponse[domain.Product], error) {
page := req.Page
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 20
}
if page <= 0 {
page = 1
}
sortBy := strings.TrimSpace(req.SortBy)
if sortBy == "" {
sortBy = "updated_at"
}
sortOrder := strings.ToLower(strings.TrimSpace(req.SortOrder))
if sortOrder == "" {
sortOrder = "desc"
}
filter := domain.RecordSearchFilter{
Query: strings.TrimSpace(req.Query),
SortBy: sortBy,
SortOrder: sortOrder,
CreatedAfter: req.CreatedAfter,
CreatedBefore: req.CreatedBefore,
Limit: pageSize,
Offset: (page - 1) * pageSize,
}
items, total, err := s.repo.ListRecords(ctx, filter)
if err != nil {
return nil, err
}
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
return &domain.PaginationResponse[domain.Product]{
Items: items,
TotalCount: total,
CurrentPage: page,
TotalPages: totalPages,
}, nil
}
func (s *Service) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
return s.repo.GetProduct(ctx, id)
}

View file

@ -85,6 +85,10 @@ func (m *MockRepository) ListProducts(ctx context.Context, filter domain.Product
return m.products, int64(len(m.products)), nil
}
func (m *MockRepository) ListRecords(ctx context.Context, filter domain.RecordSearchFilter) ([]domain.Product, int64, error) {
return m.products, int64(len(m.products)), nil
}
func (m *MockRepository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
for _, p := range m.products {
if p.ID == id {