hybrid pagination

This commit is contained in:
Mazyar
2026-05-17 17:21:36 +03:30
parent 42d0a47226
commit 6dac5b482a
39 changed files with 696 additions and 386 deletions
+7 -1
View File
@@ -179,10 +179,16 @@ func (h *Handler) Search(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
pagination := response.NewPagination(c) pagination, err := response.NewPagination(c)
if err != nil {
return response.PaginationBadRequest(c, err)
}
result, err := h.service.Search(c.Request().Context(), form, pagination) result, err := h.service.Search(c.Request().Context(), form, pagination)
if err != nil { if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to search CMS entries") return response.InternalServerError(c, "Failed to search CMS entries")
} }
+17 -4
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"tm/pkg/logger" "tm/pkg/logger"
orm "tm/pkg/mongo" orm "tm/pkg/mongo"
"tm/pkg/response"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )
@@ -17,7 +18,7 @@ type Repository interface {
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
GetByID(ctx context.Context, id string) (*CMS, error) GetByID(ctx context.Context, id string) (*CMS, error)
GetByKey(ctx context.Context, key string) (*CMS, error) GetByKey(ctx context.Context, key string) (*CMS, error)
Search(ctx context.Context, search *SearchCMSForm, pagination orm.Pagination) (*orm.PaginatedResult[CMS], error) Search(ctx context.Context, search *SearchCMSForm, pagination *response.Pagination) (*orm.PaginatedResult[CMS], error)
} }
// cmsRepository implements the Repository interface using the MongoDB ORM // cmsRepository implements the Repository interface using the MongoDB ORM
@@ -169,7 +170,7 @@ func (r *cmsRepository) Delete(ctx context.Context, id string) error {
} }
// Search searches CMS entries with filters and pagination // Search searches CMS entries with filters and pagination
func (r *cmsRepository) Search(ctx context.Context, search *SearchCMSForm, pagination orm.Pagination) (*orm.PaginatedResult[CMS], error) { func (r *cmsRepository) Search(ctx context.Context, search *SearchCMSForm, pagination *response.Pagination) (*orm.PaginatedResult[CMS], error) {
filter := bson.M{} filter := bson.M{}
if search.Search != nil && *search.Search != "" { if search.Search != nil && *search.Search != "" {
@@ -182,8 +183,20 @@ func (r *cmsRepository) Search(ctx context.Context, search *SearchCMSForm, pagin
filter["key"] = bson.M{"$regex": search.Key, "$options": "i"} filter["key"] = bson.M{"$regex": search.Key, "$options": "i"}
} }
// Use ORM to find CMS entries mongoPagination, err := orm.BuildListPagination(
result, err := r.ormRepo.FindAll(ctx, filter, pagination) pagination.Limit,
pagination.Offset,
pagination.Cursor,
pagination.SortBy,
pagination.SortOrder,
filter,
orm.ListPaginationOptions{},
)
if err != nil {
return nil, err
}
result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
if err != nil { if err != nil {
r.logger.Error("Failed to search CMS entries", map[string]interface{}{ r.logger.Error("Failed to search CMS entries", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
+2 -14
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"tm/pkg/logger" "tm/pkg/logger"
orm "tm/pkg/mongo"
"tm/pkg/response" "tm/pkg/response"
) )
@@ -197,18 +196,7 @@ func (s *cmsService) Search(ctx context.Context, form *SearchCMSForm, pagination
"pagination": pagination, "pagination": pagination,
}) })
// Convert response.Pagination to orm.Pagination result, err := s.repo.Search(ctx, form, pagination)
ormPagination := orm.Pagination{
Limit: pagination.Limit,
Skip: pagination.Offset,
SortField: pagination.SortBy,
SortOrder: 1, // Default ascending
}
if pagination.SortOrder == "desc" {
ormPagination.SortOrder = -1
}
result, err := s.repo.Search(ctx, form, ormPagination)
if err != nil { if err != nil {
s.logger.Error("Failed to search CMS entries", map[string]interface{}{ s.logger.Error("Failed to search CMS entries", map[string]interface{}{
"filters": form, "filters": form,
@@ -230,7 +218,7 @@ func (s *cmsService) Search(ctx context.Context, form *SearchCMSForm, pagination
return &CMSListResponse{ return &CMSListResponse{
CMS: cmsResponses, CMS: cmsResponses,
Meta: pagination.Response(result.TotalCount), Meta: pagination.ListMeta(result.TotalCount, result.NextCursor, result.HasMore, result.PageOffset),
}, nil }, nil
} }
+9 -1
View File
@@ -222,8 +222,16 @@ func (h *Handler) Search(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
result, err := h.service.SearchCompanies(c.Request().Context(), form, response.NewPagination(c)) pagination, err := response.NewPagination(c)
if err != nil { if err != nil {
return response.PaginationBadRequest(c, err)
}
result, err := h.service.SearchCompanies(c.Request().Context(), form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to list companies") return response.InternalServerError(c, "Failed to list companies")
} }
+19 -25
View File
@@ -20,7 +20,7 @@ type Repository interface {
GetByName(ctx context.Context, name string) (*Company, error) GetByName(ctx context.Context, name string) (*Company, error)
GetByRegistrationNumber(ctx context.Context, registrationNumber string) (*Company, error) GetByRegistrationNumber(ctx context.Context, registrationNumber string) (*Company, error)
GetByTaxID(ctx context.Context, taxID string) (*Company, error) GetByTaxID(ctx context.Context, taxID string) (*Company, error)
Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) ([]Company, int64, error) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Company], error)
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
UpdateStatus(ctx context.Context, id string, status CompanyStatus) error UpdateStatus(ctx context.Context, id string, status CompanyStatus) error
UpdateVerification(ctx context.Context, id string, isVerified, isCompliant bool, complianceNotes *string) error UpdateVerification(ctx context.Context, id string, isVerified, isCompliant bool, complianceNotes *string) error
@@ -194,7 +194,7 @@ func (r *companyRepository) Delete(ctx context.Context, id string) error {
} }
// Search retrieves companies with search and filters // Search retrieves companies with search and filters
func (r *companyRepository) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) ([]Company, int64, error) { func (r *companyRepository) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Company], error) {
// Build filter // Build filter
filter := bson.M{} filter := bson.M{}
@@ -281,34 +281,28 @@ func (r *companyRepository) Search(ctx context.Context, form *SearchForm, pagina
filter["founded_year"] = rangeFilter filter["founded_year"] = rangeFilter
} }
// Build sort mongoPagination, err := orm.BuildListPagination(
sort := "created_at" pagination.Limit,
if pagination.SortBy != "" { pagination.Offset,
sort = pagination.SortBy pagination.Cursor,
} pagination.SortBy,
pagination.SortOrder,
sortOrder := "desc" filter,
if pagination.SortOrder != "" { orm.ListPaginationOptions{},
sortOrder = pagination.SortOrder )
}
// Build pagination
paginationBuilder := orm.NewPaginationBuilder().
Limit(pagination.Limit).
Skip(pagination.Offset).
SortBy(sort, sortOrder).
Build()
// Use ORM to find users
result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder)
if err != nil { if err != nil {
r.logger.Error("Failed to list users", map[string]interface{}{ return nil, err
}
result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
if err != nil {
r.logger.Error("Failed to search companies", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return nil, 0, err return nil, err
} }
return result.Items, result.TotalCount, nil return result, nil
} }
// UpdateStatus updates company status // UpdateStatus updates company status
+4 -5
View File
@@ -321,17 +321,16 @@ func (s *companyService) Delete(ctx context.Context, id string) error {
func (s *companyService) SearchCompanies(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*CompanyListResponse, error) { func (s *companyService) SearchCompanies(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*CompanyListResponse, error) {
// Get companies using search // Get companies using search
companies, total, err := s.repository.Search(ctx, form, pagination) page, err := s.repository.Search(ctx, form, pagination)
if err != nil { if err != nil {
s.logger.Error("Failed to search companies", map[string]interface{}{ s.logger.Error("Failed to search companies", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return nil, errors.New("failed to search companies") return nil, err
} }
// Convert to responses
var companyResponses []*CompanyResponse var companyResponses []*CompanyResponse
for _, company := range companies { for _, company := range page.Items {
// Get category details for response // Get category details for response
categoryDetails, err := s.categoryService.GetByIDs(ctx, company.Tags.Categories) categoryDetails, err := s.categoryService.GetByIDs(ctx, company.Tags.Categories)
if err != nil { if err != nil {
@@ -354,7 +353,7 @@ func (s *companyService) SearchCompanies(ctx context.Context, form *SearchForm,
return &CompanyListResponse{ return &CompanyListResponse{
Companies: companyResponses, Companies: companyResponses,
Meta: pagination.Response(total), Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil }, nil
} }
+9 -1
View File
@@ -169,8 +169,16 @@ func (h *Handler) Search(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
result, err := h.service.SearchCategories(c.Request().Context(), form, response.NewPagination(c)) pagination, err := response.NewPagination(c)
if err != nil { if err != nil {
return response.PaginationBadRequest(c, err)
}
result, err := h.service.SearchCategories(c.Request().Context(), form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to search categories") return response.InternalServerError(c, "Failed to search categories")
} }
+16 -22
View File
@@ -18,7 +18,7 @@ type Repository interface {
GetByID(ctx context.Context, id string) (*Category, error) GetByID(ctx context.Context, id string) (*Category, error)
GetByIDs(ctx context.Context, ids []string) ([]Category, error) GetByIDs(ctx context.Context, ids []string) ([]Category, error)
GetByName(ctx context.Context, name string) (*Category, error) GetByName(ctx context.Context, name string) (*Category, error)
Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) ([]Category, int64, error) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Category], error)
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
TogglePublish(ctx context.Context, id string) (bool, error) TogglePublish(ctx context.Context, id string) (bool, error)
} }
@@ -178,7 +178,7 @@ func (r *categoryRepository) Delete(ctx context.Context, id string) error {
} }
// Search retrieves categories with search and filters // Search retrieves categories with search and filters
func (r *categoryRepository) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) ([]Category, int64, error) { func (r *categoryRepository) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Category], error) {
// Build filter // Build filter
filter := bson.M{} filter := bson.M{}
@@ -198,34 +198,28 @@ func (r *categoryRepository) Search(ctx context.Context, form *SearchForm, pagin
filter["published"] = *form.Published filter["published"] = *form.Published
} }
// Build sort mongoPagination, err := orm.BuildListPagination(
sort := "created_at" pagination.Limit,
if pagination.SortBy != "" { pagination.Offset,
sort = pagination.SortBy pagination.Cursor,
pagination.SortBy,
pagination.SortOrder,
filter,
orm.ListPaginationOptions{},
)
if err != nil {
return nil, err
} }
sortOrder := "desc" result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
if pagination.SortOrder != "" {
sortOrder = pagination.SortOrder
}
// Build pagination
paginationBuilder := orm.NewPaginationBuilder().
Limit(pagination.Limit).
Skip(pagination.Offset).
SortBy(sort, sortOrder).
Build()
// Use ORM to find categories
result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder)
if err != nil { if err != nil {
r.logger.Error("Failed to search categories", map[string]interface{}{ r.logger.Error("Failed to search categories", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return nil, 0, err return nil, err
} }
return result.Items, result.TotalCount, nil return result, nil
} }
// TogglePublish toggles category publish status // TogglePublish toggles category publish status
+4 -5
View File
@@ -177,23 +177,22 @@ func (s *categoryService) Delete(ctx context.Context, id string) error {
// SearchCategories searches categories with filters // SearchCategories searches categories with filters
func (s *categoryService) SearchCategories(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*CategoryListResponse, error) { func (s *categoryService) SearchCategories(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*CategoryListResponse, error) {
// Get categories using search // Get categories using search
categories, total, err := s.repository.Search(ctx, form, pagination) page, err := s.repository.Search(ctx, form, pagination)
if err != nil { if err != nil {
s.logger.Error("Failed to search categories", map[string]interface{}{ s.logger.Error("Failed to search categories", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return nil, errors.New("failed to search categories") return nil, err
} }
// Convert to responses
var categoryResponses []*CategoryResponse var categoryResponses []*CategoryResponse
for _, category := range categories { for _, category := range page.Items {
categoryResponses = append(categoryResponses, category.ToResponse()) categoryResponses = append(categoryResponses, category.ToResponse())
} }
return &CategoryListResponse{ return &CategoryListResponse{
Categories: categoryResponses, Categories: categoryResponses,
Meta: pagination.Response(total), Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil }, nil
} }
+7 -1
View File
@@ -113,10 +113,16 @@ func (h *Handler) Search(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
pagination := response.NewPagination(c) pagination, err := response.NewPagination(c)
if err != nil {
return response.PaginationBadRequest(c, err)
}
result, err := h.service.Search(c.Request().Context(), form, pagination) result, err := h.service.Search(c.Request().Context(), form, pagination)
if err != nil { if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to search contacts") return response.InternalServerError(c, "Failed to search contacts")
} }
+17 -6
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"tm/pkg/logger" "tm/pkg/logger"
orm "tm/pkg/mongo" orm "tm/pkg/mongo"
"tm/pkg/response"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
@@ -20,7 +21,7 @@ type ContactRepository interface {
GetByID(ctx context.Context, id string) (*Contact, error) GetByID(ctx context.Context, id string) (*Contact, error)
// Search searches contacts with filters and pagination // Search searches contacts with filters and pagination
Search(ctx context.Context, filters SearchContactsForm, pagination orm.Pagination) (*orm.PaginatedResult[Contact], error) Search(ctx context.Context, filters SearchContactsForm, pagination *response.Pagination) (*orm.PaginatedResult[Contact], error)
// UpdateStatus updates the status of a contact // UpdateStatus updates the status of a contact
UpdateStatus(ctx context.Context, id string, status ContactStatus, adminNotes *string, resolvedBy *string) error UpdateStatus(ctx context.Context, id string, status ContactStatus, adminNotes *string, resolvedBy *string) error
@@ -87,20 +88,17 @@ func (r *contactRepository) GetByID(ctx context.Context, id string) (*Contact, e
} }
// Search searches contacts with filters and pagination // Search searches contacts with filters and pagination
func (r *contactRepository) Search(ctx context.Context, filters SearchContactsForm, pagination orm.Pagination) (*orm.PaginatedResult[Contact], error) { func (r *contactRepository) Search(ctx context.Context, filters SearchContactsForm, pagination *response.Pagination) (*orm.PaginatedResult[Contact], error) {
filter := bson.M{} filter := bson.M{}
// Apply text search if provided
if filters.Search != nil && *filters.Search != "" { if filters.Search != nil && *filters.Search != "" {
filter["$text"] = bson.M{"$search": *filters.Search} filter["$text"] = bson.M{"$search": *filters.Search}
} }
// Apply status filter
if filters.Status != nil && *filters.Status != "" { if filters.Status != nil && *filters.Status != "" {
filter["status"] = *filters.Status filter["status"] = *filters.Status
} }
// Apply date range filters
if filters.DateFrom != nil || filters.DateTo != nil { if filters.DateFrom != nil || filters.DateTo != nil {
dateFilter := bson.M{} dateFilter := bson.M{}
if filters.DateFrom != nil { if filters.DateFrom != nil {
@@ -112,7 +110,20 @@ func (r *contactRepository) Search(ctx context.Context, filters SearchContactsFo
filter["created_at"] = dateFilter filter["created_at"] = dateFilter
} }
return r.repo.FindAll(ctx, filter, pagination) mongoPagination, err := orm.BuildListPagination(
pagination.Limit,
pagination.Offset,
pagination.Cursor,
pagination.SortBy,
pagination.SortOrder,
filter,
orm.ListPaginationOptions{},
)
if err != nil {
return nil, err
}
return r.repo.FindAll(ctx, filter, mongoPagination)
} }
// UpdateStatus updates the status of a contact // UpdateStatus updates the status of a contact
+2 -14
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"tm/pkg/logger" "tm/pkg/logger"
orm "tm/pkg/mongo"
"tm/pkg/notification" "tm/pkg/notification"
"tm/pkg/response" "tm/pkg/response"
) )
@@ -155,18 +154,7 @@ func (s *contactService) Search(ctx context.Context, form *SearchContactsForm, p
return nil, fmt.Errorf("invalid date range: date_from must be before date_to") return nil, fmt.Errorf("invalid date range: date_from must be before date_to")
} }
// Convert response.Pagination to orm.Pagination result, err := s.repo.Search(ctx, *form, pagination)
ormPagination := orm.Pagination{
Limit: pagination.Limit,
Skip: pagination.Offset,
SortField: pagination.SortBy,
SortOrder: 1, // Default ascending
}
if pagination.SortOrder == "desc" {
ormPagination.SortOrder = -1
}
result, err := s.repo.Search(ctx, *form, ormPagination)
if err != nil { if err != nil {
s.logger.Error("Failed to search contacts", map[string]interface{}{ s.logger.Error("Failed to search contacts", map[string]interface{}{
"filters": form, "filters": form,
@@ -188,7 +176,7 @@ func (s *contactService) Search(ctx context.Context, form *SearchContactsForm, p
return &ContactListResponse{ return &ContactListResponse{
Contacts: contactResponses, Contacts: contactResponses,
Meta: pagination.Response(result.TotalCount), Meta: pagination.ListMeta(result.TotalCount, result.NextCursor, result.HasMore, result.PageOffset),
}, nil }, nil
} }
+7 -1
View File
@@ -191,10 +191,16 @@ func (h *Handler) Search(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
pagination := response.NewPagination(c) pagination, err := response.NewPagination(c)
if err != nil {
return response.PaginationBadRequest(c, err)
}
result, err := h.service.Search(c.Request().Context(), form, pagination) result, err := h.service.Search(c.Request().Context(), form, pagination)
if err != nil { if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to list customers") return response.InternalServerError(c, "Failed to list customers")
} }
+17 -12
View File
@@ -24,7 +24,7 @@ type Repository interface {
GetByRole(ctx context.Context, role CustomerRole) ([]Customer, error) GetByRole(ctx context.Context, role CustomerRole) ([]Customer, error)
GetByCompanies(ctx context.Context, companies []string) ([]Customer, error) GetByCompanies(ctx context.Context, companies []string) ([]Customer, error)
GetByCompanyID(ctx context.Context, companyID string, pagination *response.Pagination) ([]*Customer, int64, error) GetByCompanyID(ctx context.Context, companyID string, pagination *response.Pagination) ([]*Customer, int64, error)
Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]Customer, int64, error) Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*orm.PaginatedResult[Customer], error)
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
} }
@@ -329,8 +329,7 @@ func (r *customerRepository) Delete(ctx context.Context, id string) error {
} }
// Search retrieves customers with search and filters // Search retrieves customers with search and filters
func (r *customerRepository) Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]Customer, int64, error) { func (r *customerRepository) Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*orm.PaginatedResult[Customer], error) {
// Build filter
filter := bson.M{} filter := bson.M{}
if form.Search != nil { if form.Search != nil {
@@ -349,23 +348,29 @@ func (r *customerRepository) Search(ctx context.Context, form *SearchCustomersFo
filter["company_id"] = *form.CompanyID filter["company_id"] = *form.CompanyID
} }
// Build pagination mongoPagination, err := orm.BuildListPagination(
paginationBuilder := orm.NewPaginationBuilder(). pagination.Limit,
Limit(pagination.Limit). pagination.Offset,
Skip(pagination.Offset). pagination.Cursor,
SortBy(pagination.SortBy, pagination.SortOrder) pagination.SortBy,
pagination.SortOrder,
filter,
orm.ListPaginationOptions{},
)
if err != nil {
return nil, err
}
// Use ORM to find customers result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder.Build())
if err != nil { if err != nil {
r.logger.Error("Failed to search customers", map[string]interface{}{ r.logger.Error("Failed to search customers", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"search": form.Search, "search": form.Search,
}) })
return nil, 0, err return nil, err
} }
return result.Items, result.TotalCount, nil return result, nil
} }
// UpdateStatus updates customer status // UpdateStatus updates customer status
+6 -6
View File
@@ -218,16 +218,16 @@ func (s *customerService) GetByID(ctx context.Context, id string) (*CustomerResp
// Search searches for customers // Search searches for customers
func (s *customerService) Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*CustomerListResponse, error) { func (s *customerService) Search(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) (*CustomerListResponse, error) {
customers, total, err := s.repository.Search(ctx, form, pagination) page, err := s.repository.Search(ctx, form, pagination)
if err != nil { if err != nil {
s.logger.Error("Failed to search customers", map[string]interface{}{ s.logger.Error("Failed to search customers", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return nil, errors.New("failed to search customers") return nil, err
} }
var customerResponses []*CustomerResponse var customerResponses []*CustomerResponse
for _, customer := range customers { for _, customer := range page.Items {
companies, err := s.loadCompaniesForCustomer(ctx, &customer) companies, err := s.loadCompaniesForCustomer(ctx, &customer)
if err != nil { if err != nil {
s.logger.Error("Failed to load companies for customer", map[string]interface{}{ s.logger.Error("Failed to load companies for customer", map[string]interface{}{
@@ -241,12 +241,12 @@ func (s *customerService) Search(ctx context.Context, form *SearchCustomersForm,
return &CustomerListResponse{ return &CustomerListResponse{
Customers: customerResponses, Customers: customerResponses,
Meta: pagination.Response(total), Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil }, nil
} }
func (s *customerService) SearchNotification(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]*CustomerNotificationResponse, error) { func (s *customerService) SearchNotification(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]*CustomerNotificationResponse, error) {
customers, _, err := s.repository.Search(ctx, form, pagination) page, err := s.repository.Search(ctx, form, pagination)
if err != nil { if err != nil {
s.logger.Error("Failed to search customers", map[string]interface{}{ s.logger.Error("Failed to search customers", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@@ -255,7 +255,7 @@ func (s *customerService) SearchNotification(ctx context.Context, form *SearchCu
} }
var customerResponses []*CustomerNotificationResponse var customerResponses []*CustomerNotificationResponse
for _, customer := range customers { for _, customer := range page.Items {
customerResponses = append(customerResponses, customer.ToNotificationResponse()) customerResponses = append(customerResponses, customer.ToNotificationResponse())
} }
+1 -1
View File
@@ -62,7 +62,7 @@ type (
type FeedbackListResponse struct { type FeedbackListResponse struct {
Feedback []*FeedbackResponse `json:"feedback"` Feedback []*FeedbackResponse `json:"feedback"`
Meta *response.Meta `json:"meta"` Meta *response.Meta `json:"-"`
} }
func (f *Feedback) ToResponse(tender *tenderResponse, company *companyResponse) *FeedbackResponse { func (f *Feedback) ToResponse(tender *tenderResponse, company *companyResponse) *FeedbackResponse {
+20 -5
View File
@@ -60,12 +60,20 @@ func (h *Handler) Search(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
feedbackList, err := h.service.Search(c.Request().Context(), *form, response.NewPagination(c)) pagination, err := response.NewPagination(c)
if err != nil { if err != nil {
return response.PaginationBadRequest(c, err)
}
feedbackList, err := h.service.Search(c.Request().Context(), *form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to retrieve feedback list") return response.InternalServerError(c, "Failed to retrieve feedback list")
} }
return response.Success(c, feedbackList.Feedback, "feedback list retrieved successfully") return response.SuccessWithMeta(c, feedbackList, feedbackList.Meta, "feedback list retrieved successfully")
} }
// GetFeedbackByID retrieves feedback by ID with loaded related data // GetFeedbackByID retrieves feedback by ID with loaded related data
@@ -217,13 +225,20 @@ func (h *Handler) PublicList(c echo.Context) error {
} }
form.Company = &companyID form.Company = &companyID
// Call service pagination, err := response.NewPagination(c)
result, err := h.service.Search(c.Request().Context(), *form, response.NewPagination(c))
if err != nil { if err != nil {
return response.PaginationBadRequest(c, err)
}
result, err := h.service.Search(c.Request().Context(), *form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to list feedback") return response.InternalServerError(c, "Failed to list feedback")
} }
return response.Success(c, result, "Feedback list retrieved successfully") return response.SuccessWithMeta(c, result, result.Meta, "Feedback list retrieved successfully")
} }
// PublicGetFeedback retrieves feedback by ID // PublicGetFeedback retrieves feedback by ID
+19 -25
View File
@@ -16,7 +16,7 @@ type Repository interface {
// Basic CRUD operations // Basic CRUD operations
Create(ctx context.Context, feedback *Feedback) error Create(ctx context.Context, feedback *Feedback) error
Update(ctx context.Context, feedback *Feedback) error Update(ctx context.Context, feedback *Feedback) error
Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) ([]Feedback, int64, error) Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Feedback], error)
GetByID(ctx context.Context, id string) (*Feedback, error) GetByID(ctx context.Context, id string) (*Feedback, error)
GetByCompanyID(ctx context.Context, companyID string, tender string) (*Feedback, error) GetByCompanyID(ctx context.Context, companyID string, tender string) (*Feedback, error)
GetByTenderID(ctx context.Context, tenderID, companyID string) (*Feedback, error) GetByTenderID(ctx context.Context, tenderID, companyID string) (*Feedback, error)
@@ -172,37 +172,31 @@ func (r *feedbackRepository) Delete(ctx context.Context, id string) error {
} }
// Search searches feedback based on criteria // Search searches feedback based on criteria
func (r *feedbackRepository) Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) ([]Feedback, int64, error) { func (r *feedbackRepository) Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Feedback], error) {
filter := r.buildSearchFilter(criteria) filter := r.buildSearchFilter(criteria)
// Build sort mongoPagination, err := orm.BuildListPagination(
sort := "created_at" pagination.Limit,
if pagination.SortBy != "" { pagination.Offset,
sort = pagination.SortBy pagination.Cursor,
} pagination.SortBy,
pagination.SortOrder,
sortOrder := "desc" filter,
if pagination.SortOrder != "" { orm.ListPaginationOptions{},
sortOrder = pagination.SortOrder )
}
// Build pagination
paginationBuilder := orm.NewPaginationBuilder().
Limit(pagination.Limit).
Skip(pagination.Offset).
SortBy(sort, sortOrder).
Build()
// Use ORM to find users
result, err := r.repo.FindAll(ctx, filter, paginationBuilder)
if err != nil { if err != nil {
r.logger.Error("Failed to list users", map[string]interface{}{ return nil, err
}
result, err := r.repo.FindAll(ctx, filter, mongoPagination)
if err != nil {
r.logger.Error("Failed to search feedback", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return nil, 0, err return nil, err
} }
return result.Items, result.TotalCount, nil return result, nil
} }
// Stats calculates feedback statistics for a company // Stats calculates feedback statistics for a company
+4 -10
View File
@@ -152,7 +152,7 @@ func (s *feedbackService) Get(ctx context.Context, id string) (*FeedbackResponse
} }
func (s *feedbackService) Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*FeedbackListResponse, error) { func (s *feedbackService) Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*FeedbackListResponse, error) {
feedbacks, total, err := s.feedbackRepo.Search(ctx, criteria, pagination) page, err := s.feedbackRepo.Search(ctx, criteria, pagination)
if err != nil { if err != nil {
s.logger.Error("Failed to list feedback", map[string]interface{}{ s.logger.Error("Failed to list feedback", map[string]interface{}{
"criteria": criteria, "criteria": criteria,
@@ -163,16 +163,10 @@ func (s *feedbackService) Search(ctx context.Context, criteria SearchForm, pagin
return nil, err return nil, err
} }
meta := &response.Meta{ meta := pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset)
Total: int(total),
Limit: pagination.Limit,
Offset: pagination.Offset,
Page: (pagination.Offset / pagination.Limit) + 1,
Pages: int((total + int64(pagination.Limit) - 1) / int64(pagination.Limit)),
}
result := make([]*FeedbackResponse, len(feedbacks)) result := make([]*FeedbackResponse, len(page.Items))
for i, feedback := range feedbacks { for i, feedback := range page.Items {
tender, company, _ := s.getDetails(ctx, feedback.TenderID, *feedback.CompanyID) tender, company, _ := s.getDetails(ctx, feedback.TenderID, *feedback.CompanyID)
result[i] = feedback.ToResponse(tender, company) result[i] = feedback.ToResponse(tender, company)
} }
+9 -1
View File
@@ -185,8 +185,16 @@ func (h *Handler) SearchInquiries(c echo.Context) error {
} }
// Call service // Call service
inquiries, err := h.service.Search(c.Request().Context(), form, response.NewPagination(c)) pagination, err := response.NewPagination(c)
if err != nil { if err != nil {
return response.PaginationBadRequest(c, err)
}
inquiries, err := h.service.Search(c.Request().Context(), form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to search inquiries") return response.InternalServerError(c, "Failed to search inquiries")
} }
+16 -22
View File
@@ -16,7 +16,7 @@ type Repository interface {
Create(ctx context.Context, inquiry *Inquiry) error Create(ctx context.Context, inquiry *Inquiry) error
GetByID(ctx context.Context, id string) (*Inquiry, error) GetByID(ctx context.Context, id string) (*Inquiry, error)
UpdateStatus(ctx context.Context, id string, status, reason, changedBy, description string) error UpdateStatus(ctx context.Context, id string, status, reason, changedBy, description string) error
Search(ctx context.Context, form *SearchInquiriesForm, pagination *response.Pagination) ([]Inquiry, int64, error) Search(ctx context.Context, form *SearchInquiriesForm, pagination *response.Pagination) (*orm.PaginatedResult[Inquiry], error)
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
CheckPendingInquiry(ctx context.Context, email string) (*Inquiry, error) CheckPendingInquiry(ctx context.Context, email string) (*Inquiry, error)
CheckPendingInquiryByPhone(ctx context.Context, phone string) (*Inquiry, error) CheckPendingInquiryByPhone(ctx context.Context, phone string) (*Inquiry, error)
@@ -206,7 +206,7 @@ func (r *inquiryRepository) CheckPendingInquiryByPhone(ctx context.Context, phon
} }
// Search retrieves inquiries with pagination and filters // Search retrieves inquiries with pagination and filters
func (r *inquiryRepository) Search(ctx context.Context, form *SearchInquiriesForm, pagination *response.Pagination) ([]Inquiry, int64, error) { func (r *inquiryRepository) Search(ctx context.Context, form *SearchInquiriesForm, pagination *response.Pagination) (*orm.PaginatedResult[Inquiry], error) {
filter := bson.M{} filter := bson.M{}
// Add search filter // Add search filter
@@ -223,34 +223,28 @@ func (r *inquiryRepository) Search(ctx context.Context, form *SearchInquiriesFor
filter["status"] = *form.Status filter["status"] = *form.Status
} }
// Build sort mongoPagination, err := orm.BuildListPagination(
sort := "created_at" pagination.Limit,
if pagination.SortBy != "" { pagination.Offset,
sort = pagination.SortBy pagination.Cursor,
pagination.SortBy,
pagination.SortOrder,
filter,
orm.ListPaginationOptions{},
)
if err != nil {
return nil, err
} }
sortOrder := "desc" result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
if pagination.SortOrder != "" {
sortOrder = pagination.SortOrder
}
// Build pagination
paginationBuilder := orm.NewPaginationBuilder().
Limit(pagination.Limit).
Skip(pagination.Offset).
SortBy(sort, sortOrder).
Build()
// Use ORM to find inquiries
result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder)
if err != nil { if err != nil {
r.logger.Error("Failed to search inquiries", map[string]interface{}{ r.logger.Error("Failed to search inquiries", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return nil, 0, err return nil, err
} }
return result.Items, result.TotalCount, nil return result, nil
} }
// Delete deletes an inquiry // Delete deletes an inquiry
+5 -7
View File
@@ -273,29 +273,27 @@ func (s *inquiryService) Search(ctx context.Context, form *SearchInquiriesForm,
"offset": pagination.Offset, "offset": pagination.Offset,
}) })
// Get inquiries page, err := s.repository.Search(ctx, form, pagination)
inquiries, total, err := s.repository.Search(ctx, form, pagination)
if err != nil { if err != nil {
s.logger.Error("Failed to search inquiries", map[string]interface{}{ s.logger.Error("Failed to search inquiries", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return nil, errors.New("failed to search inquiries") return nil, err
} }
// Convert to responses
var inquiryResponses []*InquiryResponse var inquiryResponses []*InquiryResponse
for _, inquiry := range inquiries { for _, inquiry := range page.Items {
inquiryResponses = append(inquiryResponses, inquiry.ToResponse()) inquiryResponses = append(inquiryResponses, inquiry.ToResponse())
} }
s.logger.Info("Inquiries search completed successfully", map[string]interface{}{ s.logger.Info("Inquiries search completed successfully", map[string]interface{}{
"total_found": total, "total_found": page.TotalCount,
"returned": len(inquiryResponses), "returned": len(inquiryResponses),
}) })
return &InquiryListResponse{ return &InquiryListResponse{
Inquiries: inquiryResponses, Inquiries: inquiryResponses,
Meta: pagination.Response(total), Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil }, nil
} }
+21 -7
View File
@@ -87,11 +87,16 @@ func (h *NotificationHandler) GetNotifications(c echo.Context) error {
return response.ValidationError(c, err.Error(), "") return response.ValidationError(c, err.Error(), "")
} }
pagination := response.NewPagination(c) pagination, err := response.NewPagination(c)
if err != nil {
return response.PaginationBadRequest(c, err)
}
// Call service
notifications, err := h.service.GetNotifications(c.Request().Context(), form, pagination) notifications, err := h.service.GetNotifications(c.Request().Context(), form, pagination)
if err != nil { if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to get notifications") return response.InternalServerError(c, "Failed to get notifications")
} }
@@ -141,11 +146,16 @@ func (h *NotificationHandler) AdminNotifications(c echo.Context) error {
form.Recipient = userID form.Recipient = userID
form.Status = "sent" form.Status = "sent"
pagination := response.NewPagination(c) pagination, err := response.NewPagination(c)
if err != nil {
return response.PaginationBadRequest(c, err)
}
// Call service
notifications, err := h.service.GetNotifications(c.Request().Context(), form, pagination) notifications, err := h.service.GetNotifications(c.Request().Context(), form, pagination)
if err != nil { if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to get notifications") return response.InternalServerError(c, "Failed to get notifications")
} }
@@ -254,12 +264,16 @@ func (h *NotificationHandler) CustomerNotifications(c echo.Context) error {
form.Recipient = userID form.Recipient = userID
form.Status = "sent" form.Status = "sent"
pagination := response.NewPagination(c) pagination, err := response.NewPagination(c)
if err != nil {
return response.PaginationBadRequest(c, err)
}
// Call service
notifications, err := h.service.GetNotifications(c.Request().Context(), form, pagination) notifications, err := h.service.GetNotifications(c.Request().Context(), form, pagination)
if err != nil { if err != nil {
// Log the actual error for debugging if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
c.Logger().Error("GetNotifications failed", map[string]interface{}{ c.Logger().Error("GetNotifications failed", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"status": form.Status, "status": form.Status,
+15 -35
View File
@@ -11,7 +11,6 @@ import (
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
mongopkg "go.mongodb.org/mongo-driver/v2/mongo" mongopkg "go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
) )
var ( var (
@@ -45,7 +44,7 @@ type Notification struct {
type Repository interface { type Repository interface {
Create(ctx context.Context, notification *Notification) error Create(ctx context.Context, notification *Notification) error
GetByID(ctx context.Context, id string) (*Notification, error) GetByID(ctx context.Context, id string) (*Notification, error)
GetByUserID(ctx context.Context, userID string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) ([]Notification, int64, error) GetByUserID(ctx context.Context, userID string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error)
Update(ctx context.Context, notification *Notification) error Update(ctx context.Context, notification *Notification) error
MarkAsSeen(ctx context.Context, notificationID, userID string) error MarkAsSeen(ctx context.Context, notificationID, userID string) error
MarkAllAsSeen(ctx context.Context, userID string) error MarkAllAsSeen(ctx context.Context, userID string) error
@@ -108,7 +107,7 @@ func (r *notificationRepository) GetByID(ctx context.Context, id string) (*Notif
} }
// GetByUserID retrieves notifications for a specific user with filters // GetByUserID retrieves notifications for a specific user with filters
func (r *notificationRepository) GetByUserID(ctx context.Context, userID string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) ([]Notification, int64, error) { func (r *notificationRepository) GetByUserID(ctx context.Context, userID string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) {
filter := bson.M{"user_id": userID} filter := bson.M{"user_id": userID}
if status != "" { if status != "" {
@@ -131,48 +130,29 @@ func (r *notificationRepository) GetByUserID(ctx context.Context, userID string,
filter["type"] = notificationType filter["type"] = notificationType
} }
// Get total count mongoPagination, err := orm.BuildListPagination(
total, err := r.collection.CountDocuments(ctx, filter) pagination.Limit,
pagination.Offset,
pagination.Cursor,
pagination.SortBy,
pagination.SortOrder,
filter,
orm.ListPaginationOptions{},
)
if err != nil { if err != nil {
r.logger.Error("Failed to count notifications", map[string]interface{}{ return nil, err
"error": err.Error(),
"user_id": userID,
})
return nil, 0, err
} }
// Build find options result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
findOptions := options.Find()
if pagination.Limit > 0 {
findOptions.SetLimit(int64(pagination.Limit))
}
if pagination.Offset > 0 {
findOptions.SetSkip(int64(pagination.Offset))
}
// Sort by created_at descending
findOptions.SetSort(bson.D{{Key: "created_at", Value: -1}})
cursor, err := r.collection.Find(ctx, filter, findOptions)
if err != nil { if err != nil {
r.logger.Error("Failed to find notifications", map[string]interface{}{ r.logger.Error("Failed to find notifications", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"user_id": userID, "user_id": userID,
}) })
return nil, 0, err return nil, err
}
defer cursor.Close(ctx)
var notifications []Notification
if err := cursor.All(ctx, &notifications); err != nil {
r.logger.Error("Failed to decode notifications", map[string]interface{}{
"error": err.Error(),
"user_id": userID,
})
return nil, 0, err
} }
return notifications, total, nil return result, nil
} }
// Update updates a notification // Update updates a notification
+3 -15
View File
@@ -194,7 +194,7 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF
"offset": pagination.Offset, "offset": pagination.Offset,
}) })
notifications, total, err := s.repository.GetByUserID(ctx, req.Recipient, req.Status, req.Seen, req.Priority, req.EventType, req.Type, pagination) page, err := s.repository.GetByUserID(ctx, req.Recipient, req.Status, req.Seen, req.Priority, req.EventType, req.Type, pagination)
if err != nil { if err != nil {
s.logger.Error("Failed to get notifications", map[string]interface{}{ s.logger.Error("Failed to get notifications", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@@ -203,7 +203,7 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF
} }
notificationsResponse := make([]*NotificationResponse, 0) notificationsResponse := make([]*NotificationResponse, 0)
for _, notification := range notifications { for _, notification := range page.Items {
usr, _ := s.userService.GetUserByID(ctx, notification.UserID) usr, _ := s.userService.GetUserByID(ctx, notification.UserID)
cust, _ := s.customerService.GetByID(ctx, notification.UserID) cust, _ := s.customerService.GetByID(ctx, notification.UserID)
@@ -250,21 +250,9 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF
}) })
} }
// Calculate total pages
totalPages := int(total) / pagination.Limit
if int(total)%pagination.Limit > 0 {
totalPages++
}
return &NotificationListResponse{ return &NotificationListResponse{
Notifications: notificationsResponse, Notifications: notificationsResponse,
Meta: &response.Meta{ Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
Total: int(total),
Limit: pagination.Limit,
Offset: pagination.Offset,
Page: (pagination.Offset / pagination.Limit) + 1,
Pages: totalPages,
},
}, nil }, nil
} }
+24 -13
View File
@@ -177,11 +177,15 @@ func (h *TenderHandler) Search(c echo.Context) error {
return response.ValidationError(c, "Validation failed", err.Error()) return response.ValidationError(c, "Validation failed", err.Error())
} }
// Call service pagination, err := response.NewPagination(c)
tenders, err := h.service.Search(c.Request().Context(), form, response.NewPagination(c))
if err != nil { if err != nil {
if isInvalidPaginationCursor(err) { return response.PaginationBadRequest(c, err)
return response.ValidationError(c, "Invalid pagination cursor", err.Error()) }
tenders, err := h.service.Search(c.Request().Context(), form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
} }
return response.InternalServerError(c, "Failed to list tenders") return response.InternalServerError(c, "Failed to list tenders")
} }
@@ -259,10 +263,15 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
} }
form.CustomerID = customerID form.CustomerID = customerID
result, err := h.service.Search(c.Request().Context(), form, response.NewPagination(c)) pagination, err := response.NewPagination(c)
if err != nil { if err != nil {
if isInvalidPaginationCursor(err) { return response.PaginationBadRequest(c, err)
return response.ValidationError(c, "Invalid pagination cursor", err.Error()) }
result, err := h.service.Search(c.Request().Context(), form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
} }
return response.InternalServerError(c, "Failed to retrieve tenders") return response.InternalServerError(c, "Failed to retrieve tenders")
} }
@@ -335,10 +344,15 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error {
} }
form.CustomerID = customerID form.CustomerID = customerID
result, err := h.service.Recommend(c.Request().Context(), form, response.NewPagination(c)) pagination, err := response.NewPagination(c)
if err != nil { if err != nil {
if isInvalidPaginationCursor(err) { return response.PaginationBadRequest(c, err)
return response.ValidationError(c, "Invalid pagination cursor", err.Error()) }
result, err := h.service.Recommend(c.Request().Context(), form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
} }
return response.InternalServerError(c, "Failed to retrieve tenders") return response.InternalServerError(c, "Failed to retrieve tenders")
} }
@@ -664,6 +678,3 @@ func (h *TenderHandler) GetAllAISummaries(c echo.Context) error {
return response.Success(c, summaries, "AI summaries retrieved successfully") return response.Success(c, summaries, "AI summaries retrieved successfully")
} }
func isInvalidPaginationCursor(err error) bool {
return errors.Is(err, orm.ErrInvalidCursor)
}
+14 -15
View File
@@ -40,8 +40,6 @@ func collectionName() string {
return "tenders" return "tenders"
} }
// deepOffsetSkipCountThreshold skips CountDocuments when offset pagination goes this deep.
const deepOffsetSkipCountThreshold = 1000
// SearchPageResult is the paginated outcome of a tender search/list query. // SearchPageResult is the paginated outcome of a tender search/list query.
type SearchPageResult struct { type SearchPageResult struct {
@@ -49,6 +47,7 @@ type SearchPageResult struct {
TotalCount int64 // -1 when count was skipped TotalCount int64 // -1 when count was skipped
NextCursor string NextCursor string
HasMore bool HasMore bool
PageOffset int
} }
// tenderSearchListProjection excludes large fields not needed for list/search API payloads. // tenderSearchListProjection excludes large fields not needed for list/search API payloads.
@@ -370,21 +369,20 @@ func (r *tenderRepository) Search(ctx context.Context, form *SearchForm, paginat
sortOrder = pagination.SortOrder sortOrder = pagination.SortOrder
} }
skipCount := pagination.Cursor != "" || pagination.Offset >= deepOffsetSkipCountThreshold mongoPagination, err := orm.BuildListPagination(
pagination.Limit,
paginationBuilder := orm.NewPaginationBuilder(). pagination.Offset,
Limit(pagination.Limit). pagination.Cursor,
SortBy(sort, sortOrder). sort,
Projection(tenderSearchListProjection()). sortOrder,
SkipCount(skipCount) filter,
orm.ListPaginationOptions{Projection: tenderSearchListProjection()},
if pagination.Cursor != "" { )
paginationBuilder.Cursor(pagination.Cursor) if err != nil {
} else { return nil, err
paginationBuilder.Skip(pagination.Offset)
} }
result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder.Build()) result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
if err != nil { if err != nil {
r.logger.Error("Failed to search tenders", map[string]interface{}{ r.logger.Error("Failed to search tenders", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@@ -397,6 +395,7 @@ func (r *tenderRepository) Search(ctx context.Context, form *SearchForm, paginat
TotalCount: result.TotalCount, TotalCount: result.TotalCount,
NextCursor: result.NextCursor, NextCursor: result.NextCursor,
HasMore: result.HasMore, HasMore: result.HasMore,
PageOffset: result.PageOffset,
}, nil }, nil
} }
+4 -4
View File
@@ -648,7 +648,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
return &SearchResponse{ return &SearchResponse{
Tenders: tenderResponses, Tenders: tenderResponses,
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore), Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil }, nil
} }
@@ -667,7 +667,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
return &SearchResponse{ return &SearchResponse{
Tenders: tenderResponses, Tenders: tenderResponses,
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore), Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, },
nil nil
} }
@@ -690,7 +690,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
return &SearchResponse{ return &SearchResponse{
Tenders: tenderResponses, Tenders: tenderResponses,
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore), Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, },
nil nil
} }
@@ -757,7 +757,7 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat
return &SearchResponse{ return &SearchResponse{
Tenders: tenderResponses, Tenders: tenderResponses,
Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore), Metadata: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil }, nil
} }
+9 -1
View File
@@ -311,8 +311,16 @@ func (h *Handler) ListUsers(c echo.Context) error {
} }
// Call service // Call service
users, err := h.service.Search(c.Request().Context(), form, response.NewPagination(c)) pagination, err := response.NewPagination(c)
if err != nil { if err != nil {
return response.PaginationBadRequest(c, err)
}
users, err := h.service.Search(c.Request().Context(), form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to list users") return response.InternalServerError(c, "Failed to list users")
} }
+17 -23
View File
@@ -22,7 +22,7 @@ type Repository interface {
GetByEmail(ctx context.Context, email string) (*User, error) GetByEmail(ctx context.Context, email string) (*User, error)
GetByUsername(ctx context.Context, username string) (*User, error) GetByUsername(ctx context.Context, username string) (*User, error)
GetByIDs(ctx context.Context, userIDs []string) ([]User, error) GetByIDs(ctx context.Context, userIDs []string) ([]User, error)
Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) ([]User, int64, error) Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*orm.PaginatedResult[User], error)
} }
// userRepository implements the Repository interface using the MongoDB ORM // userRepository implements the Repository interface using the MongoDB ORM
@@ -280,8 +280,8 @@ func (r *userRepository) GetByIDs(ctx context.Context, userIDs []string) ([]User
return users.Items, nil return users.Items, nil
} }
// List retrieves users with pagination // Search retrieves users with pagination
func (r *userRepository) Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) ([]User, int64, error) { func (r *userRepository) Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*orm.PaginatedResult[User], error) {
filter := bson.M{} filter := bson.M{}
if form.Search != nil { if form.Search != nil {
@@ -300,32 +300,26 @@ func (r *userRepository) Search(ctx context.Context, form *SearchUsersForm, pagi
filter["role"] = form.Role filter["role"] = form.Role
} }
// Build sort mongoPagination, err := orm.BuildListPagination(
sort := "created_at" pagination.Limit,
if pagination.SortBy != "" { pagination.Offset,
sort = pagination.SortBy pagination.Cursor,
pagination.SortBy,
pagination.SortOrder,
filter,
orm.ListPaginationOptions{},
)
if err != nil {
return nil, err
} }
sortOrder := "desc" result, err := r.ormRepo.FindAll(ctx, filter, mongoPagination)
if pagination.SortOrder != "" {
sortOrder = pagination.SortOrder
}
// Build pagination
paginationBuilder := orm.NewPaginationBuilder().
Limit(pagination.Limit).
Skip(pagination.Offset).
SortBy(sort, sortOrder).
Build()
// Use ORM to find users
result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder)
if err != nil { if err != nil {
r.logger.Error("Failed to list users", map[string]interface{}{ r.logger.Error("Failed to list users", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return nil, 0, err return nil, err
} }
return result.Items, result.TotalCount, nil return result, nil
} }
+4 -5
View File
@@ -278,23 +278,22 @@ func (s *userService) GetUsersByIDs(ctx context.Context, userIDs []string) (*Use
// Search searches users with search and filters // Search searches users with search and filters
func (s *userService) Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*UserListResponse, error) { func (s *userService) Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*UserListResponse, error) {
// Get users // Get users
users, total, err := s.repository.Search(ctx, form, pagination) page, err := s.repository.Search(ctx, form, pagination)
if err != nil { if err != nil {
s.logger.Error("Failed to list users", map[string]interface{}{ s.logger.Error("Failed to list users", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
return nil, errors.New("failed to list users") return nil, err
} }
// Convert to responses
var userResponses []*UserResponse var userResponses []*UserResponse
for _, user := range users { for _, user := range page.Items {
userResponses = append(userResponses, user.ToResponse()) userResponses = append(userResponses, user.ToResponse())
} }
return &UserListResponse{ return &UserListResponse{
Users: userResponses, Users: userResponses,
Meta: pagination.Response(total), Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil }, nil
} }
+11 -3
View File
@@ -19,9 +19,11 @@ type pageCursor struct {
ValueType string `json:"value_type"` // i64, f64, str, oid ValueType string `json:"value_type"` // i64, f64, str, oid
SortValue string `json:"sort_value"` SortValue string `json:"sort_value"`
ID string `json:"id"` ID string `json:"id"`
FiltersHash string `json:"filters_hash,omitempty"`
PageOffset int `json:"page_offset,omitempty"`
} }
func encodePageCursor(sortField string, sortOrder int, sortValue interface{}, id string) (string, error) { func encodePageCursor(sortField string, sortOrder int, sortValue interface{}, id, filtersHash string, pageOffset int) (string, error) {
if sortField == "" { if sortField == "" {
return "", fmt.Errorf("%w: sort field is required", ErrInvalidCursor) return "", fmt.Errorf("%w: sort field is required", ErrInvalidCursor)
} }
@@ -41,16 +43,22 @@ func encodePageCursor(sortField string, sortOrder int, sortValue interface{}, id
ValueType: valueType, ValueType: valueType,
SortValue: sortValueStr, SortValue: sortValueStr,
ID: id, ID: id,
FiltersHash: filtersHash,
PageOffset: pageOffset,
}) })
if err != nil { if err != nil {
return "", fmt.Errorf("failed to marshal cursor: %w", err) return "", fmt.Errorf("failed to marshal cursor: %w", err)
} }
return base64.StdEncoding.EncodeToString(payload), nil return base64.RawURLEncoding.EncodeToString(payload), nil
} }
func decodePageCursor(encoded string) (*pageCursor, error) { func decodePageCursor(encoded string) (*pageCursor, error) {
cursorBytes, err := base64.StdEncoding.DecodeString(encoded) cursorBytes, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
// Accept standard Base64 cursors issued before URL-safe encoding.
cursorBytes, err = base64.StdEncoding.DecodeString(encoded)
}
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: invalid cursor encoding", ErrInvalidCursor) return nil, fmt.Errorf("%w: invalid cursor encoding", ErrInvalidCursor)
} }
+55
View File
@@ -0,0 +1,55 @@
package mongo
import (
"strings"
"testing"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestEncodeDecodePageCursor(t *testing.T) {
encoded, err := encodePageCursor("created_at", -1, int64(1700000000), "507f1f77bcf86cd799439011", "abc123", 10)
if err != nil {
t.Fatalf("encode: %v", err)
}
if strings.Contains(encoded, "+") || strings.Contains(encoded, "/") {
t.Fatalf("expected URL-safe cursor, got %q", encoded)
}
decoded, err := decodePageCursor(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if decoded.SortField != "created_at" || decoded.SortOrder != -1 {
t.Fatalf("unexpected sort: %+v", decoded)
}
if decoded.FiltersHash != "abc123" || decoded.PageOffset != 10 {
t.Fatalf("unexpected metadata: %+v", decoded)
}
}
func TestBuildListPaginationCursorAndOffsetConflict(t *testing.T) {
_, err := BuildListPagination(10, 5, "cursor-token", "created_at", "desc", bson.M{}, ListPaginationOptions{})
if err == nil {
t.Fatal("expected error when cursor and offset are both set")
}
}
func TestBuildListPaginationOffsetMode(t *testing.T) {
p, err := BuildListPagination(10, 20, "", "created_at", "desc", bson.M{"status": "active"}, ListPaginationOptions{})
if err != nil {
t.Fatalf("build: %v", err)
}
if p.Skip != 20 || p.Limit != 10 || p.Cursor != "" {
t.Fatalf("unexpected pagination: %+v", p)
}
}
func TestHashFilterStable(t *testing.T) {
a := HashFilter(bson.M{"status": "active"})
b := HashFilter(bson.M{"status": "active"})
if a == "" || a != b {
t.Fatalf("expected stable non-empty hash, got %q and %q", a, b)
}
}
+21
View File
@@ -0,0 +1,21 @@
package mongo
import (
"crypto/sha256"
"encoding/hex"
"go.mongodb.org/mongo-driver/v2/bson"
)
// HashFilter returns a stable SHA-256 hex digest of a MongoDB filter for cursor binding.
func HashFilter(filter bson.M) string {
if len(filter) == 0 {
return ""
}
raw, err := bson.Marshal(filter)
if err != nil {
return ""
}
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
+78
View File
@@ -0,0 +1,78 @@
package mongo
import (
"fmt"
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
// DefaultListLimit is the default page size for admin list endpoints.
DefaultListLimit = 10
// MaxListLimit is the maximum allowed page size.
MaxListLimit = 100
)
// ListPaginationOptions configures optional list pagination behavior.
type ListPaginationOptions struct {
Projection bson.M
}
// BuildListPagination converts API pagination parameters into MongoDB pagination options.
// filter is used to bind cursors to the active query filters via a hash.
func BuildListPagination(
limit, offset int,
cursor, sortField, sortOrder string,
filter bson.M,
opts ListPaginationOptions,
) (Pagination, error) {
if limit <= 0 {
limit = DefaultListLimit
}
if limit > MaxListLimit {
return Pagination{}, fmt.Errorf("%w: limit must be between 1 and %d", ErrInvalidPagination, MaxListLimit)
}
if sortField == "" {
sortField = "created_at"
}
if sortOrder == "" {
sortOrder = "desc"
}
sortOrderInt := -1
if sortOrder == "asc" {
sortOrderInt = 1
}
filtersHash := HashFilter(filter)
pb := NewPaginationBuilder().
Limit(limit).
SortBy(sortField, sortOrder)
if len(opts.Projection) > 0 {
pb.Projection(opts.Projection)
}
if cursor != "" {
pageCursor, err := decodePageCursor(cursor)
if err != nil {
return Pagination{}, err
}
if !pageCursor.matches(sortField, sortOrderInt) {
return Pagination{}, fmt.Errorf("%w: cursor does not match requested sort", ErrInvalidCursor)
}
if pageCursor.FiltersHash != filtersHash {
return Pagination{}, fmt.Errorf("%w: cursor does not match current filters", ErrInvalidCursor)
}
pb.Cursor(cursor)
} else {
if offset < 0 {
return Pagination{}, fmt.Errorf("%w: offset cannot be negative", ErrInvalidPagination)
}
pb.Skip(offset)
}
return pb.Build(), nil
}
+22 -6
View File
@@ -37,6 +37,7 @@ type PaginatedResult[T any] struct {
TotalCount int64 `json:"totalCount"` // Total number of documents matching the filter TotalCount int64 `json:"totalCount"` // Total number of documents matching the filter
NextCursor string `json:"nextCursor"` // Base64 encoded cursor for next page NextCursor string `json:"nextCursor"` // Base64 encoded cursor for next page
HasMore bool `json:"hasMore"` // Whether there are more documents HasMore bool `json:"hasMore"` // Whether there are more documents
PageOffset int `json:"pageOffset"` // Zero-based offset of the first item in this page
} }
// Repository defines the interface for MongoDB operations // Repository defines the interface for MongoDB operations
@@ -144,6 +145,16 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P
return nil, fmt.Errorf("failed to clone filter for count: %w", err) return nil, fmt.Errorf("failed to clone filter for count: %w", err)
} }
filtersHash := HashFilter(filter)
pageOffset := pagination.Skip
if pagination.Cursor != "" {
pageCursor, err := decodePageCursor(pagination.Cursor)
if err != nil {
return nil, err
}
pageOffset = pageCursor.PageOffset
}
// Build find options // Build find options
findOptions := options.Find(). findOptions := options.Find().
SetSort(sort). SetSort(sort).
@@ -155,7 +166,7 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P
// Handle cursor-based pagination // Handle cursor-based pagination
if pagination.Cursor != "" { if pagination.Cursor != "" {
cursorFilter, err := r.buildCursorFilter(pagination.Cursor, pagination.SortField, pagination.SortOrder) cursorFilter, err := r.buildCursorFilter(pagination.Cursor, pagination.SortField, pagination.SortOrder, filtersHash)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -209,12 +220,13 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P
// Generate next cursor // Generate next cursor
var nextCursor string var nextCursor string
if hasMore && len(items) > 0 { if hasMore && len(items) > 0 {
nextCursor, err = r.generateCursor(items[len(items)-1], pagination.SortField, pagination.SortOrder) nextPageOffset := pageOffset + pagination.Limit
nextCursor, err = r.generateCursor(items[len(items)-1], pagination.SortField, pagination.SortOrder, filtersHash, nextPageOffset)
if err != nil { if err != nil {
r.logger.Error("Failed to generate next cursor", map[string]interface{}{ r.logger.Error("Failed to generate next cursor", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
// Don't fail the entire query, just leave cursor empty return nil, fmt.Errorf("failed to generate pagination cursor: %w", err)
} }
} }
@@ -223,6 +235,7 @@ func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination P
TotalCount: totalCount, TotalCount: totalCount,
NextCursor: nextCursor, NextCursor: nextCursor,
HasMore: hasMore, HasMore: hasMore,
PageOffset: pageOffset,
}, nil }, nil
} }
@@ -454,7 +467,7 @@ func (r *repository[T]) validatePagination(pagination Pagination) error {
} }
// buildCursorFilter builds a filter for cursor-based pagination. // buildCursorFilter builds a filter for cursor-based pagination.
func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder int) (bson.M, error) { func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder int, filtersHash string) (bson.M, error) {
pageCursor, err := decodePageCursor(cursor) pageCursor, err := decodePageCursor(cursor)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -462,11 +475,14 @@ func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder in
if !pageCursor.matches(sortField, sortOrder) { if !pageCursor.matches(sortField, sortOrder) {
return nil, fmt.Errorf("%w: cursor does not match requested sort", ErrInvalidCursor) return nil, fmt.Errorf("%w: cursor does not match requested sort", ErrInvalidCursor)
} }
if pageCursor.FiltersHash != filtersHash {
return nil, fmt.Errorf("%w: cursor does not match current filters", ErrInvalidCursor)
}
return pageCursor.mongoFilter() return pageCursor.mongoFilter()
} }
// generateCursor generates an opaque cursor from the last document in a page. // generateCursor generates an opaque cursor from the last document in a page.
func (r *repository[T]) generateCursor(doc T, sortField string, sortOrder int) (string, error) { func (r *repository[T]) generateCursor(doc T, sortField string, sortOrder int, filtersHash string, pageOffset int) (string, error) {
docBytes, err := bson.Marshal(doc) docBytes, err := bson.Marshal(doc)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to marshal document: %w", err) return "", fmt.Errorf("failed to marshal document: %w", err)
@@ -487,7 +503,7 @@ func (r *repository[T]) generateCursor(doc T, sortField string, sortOrder int) (
return "", err return "", err
} }
return encodePageCursor(sortField, sortOrder, sortValue, idHex) return encodePageCursor(sortField, sortOrder, sortValue, idHex, filtersHash, pageOffset)
} }
// getModelID extracts the ID from a model // getModelID extracts the ID from a model
+105
View File
@@ -0,0 +1,105 @@
package response
import (
"errors"
"fmt"
"strconv"
"github.com/labstack/echo/v4"
)
const (
// DefaultListLimit is the default page size for admin list endpoints.
DefaultListLimit = 10
// MaxListLimit is the maximum allowed page size.
MaxListLimit = 100
)
var (
// ErrInvalidPagination is returned when pagination query parameters are invalid.
ErrInvalidPagination = errors.New("invalid pagination parameters")
// ErrPaginationConflict is returned when cursor and offset are both supplied.
ErrPaginationConflict = errors.New("cursor and offset pagination cannot be used together")
)
// NewPagination parses and validates hybrid pagination query parameters from the request.
func NewPagination(c echo.Context) (*Pagination, error) {
limit := DefaultListLimit
if limitStr := c.QueryParam("limit"); limitStr != "" {
parsed, err := strconv.Atoi(limitStr)
if err != nil {
return nil, fmt.Errorf("%w: limit must be an integer", ErrInvalidPagination)
}
limit = parsed
}
if limit < 1 || limit > MaxListLimit {
return nil, fmt.Errorf("%w: limit must be between 1 and %d", ErrInvalidPagination, MaxListLimit)
}
cursor := c.QueryParam("cursor")
offsetParam := c.QueryParam("offset")
if cursor != "" && offsetParam != "" {
return nil, ErrPaginationConflict
}
offset := 0
if offsetParam != "" {
parsed, err := strconv.Atoi(offsetParam)
if err != nil {
return nil, fmt.Errorf("%w: offset must be an integer", ErrInvalidPagination)
}
if parsed < 0 {
return nil, fmt.Errorf("%w: offset cannot be negative", ErrInvalidPagination)
}
offset = parsed
}
sortBy := c.QueryParam("sort_by")
if sortBy == "" {
sortBy = "created_at"
}
sortOrder := c.QueryParam("sort_order")
if sortOrder == "" {
sortOrder = "desc"
}
return &Pagination{
Limit: limit,
Offset: offset,
Cursor: cursor,
SortBy: sortBy,
SortOrder: sortOrder,
}, nil
}
// ListMeta builds pagination metadata for a list page.
// pageOffset is the zero-based index of the first item in this page (from offset or cursor).
func (p *Pagination) ListMeta(total int64, nextCursor string, hasMore bool, pageOffset int) *Meta {
meta := &Meta{
Limit: p.Limit,
Offset: pageOffset,
HasMore: hasMore,
}
if hasMore && nextCursor != "" {
meta.NextCursor = nextCursor
}
if total >= 0 {
meta.Total = int(total)
if p.Limit > 0 {
meta.Page = pageOffset/p.Limit + 1
meta.Pages = int((total + int64(p.Limit) - 1) / int64(p.Limit))
}
}
return meta
}
// ListMetaFromPage builds metadata from a paginated repository result.
func (p *Pagination) ListMetaFromPage(total int64, nextCursor string, hasMore bool, pageOffset int) *Meta {
return p.ListMeta(total, nextCursor, hasMore, pageOffset)
}
// IsPaginationError reports whether err is a client-facing pagination error.
func IsPaginationError(err error) bool {
return errors.Is(err, ErrInvalidPagination) || errors.Is(err, ErrPaginationConflict)
}
+47
View File
@@ -0,0 +1,47 @@
package response
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
)
func TestNewPaginationDefaults(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/?limit=10&offset=0&sort_by=created_at&sort_order=desc", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
p, err := NewPagination(c)
if err != nil {
t.Fatalf("NewPagination: %v", err)
}
if p.Limit != 10 || p.Offset != 0 || p.Cursor != "" {
t.Fatalf("unexpected pagination: %+v", p)
}
}
func TestNewPaginationCursorConflict(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/?limit=10&offset=5&cursor=abc", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
_, err := NewPagination(c)
if !IsPaginationError(err) {
t.Fatalf("expected pagination conflict, got %v", err)
}
}
func TestListMetaOmitsNextCursorOnLastPage(t *testing.T) {
p := &Pagination{Limit: 10, Offset: 0}
meta := p.ListMeta(15, "should-not-appear", false, 0)
if meta.HasMore || meta.NextCursor != "" {
t.Fatalf("expected no next cursor on last page: %+v", meta)
}
if meta.Page != 1 || meta.Pages != 2 || meta.Total != 15 {
t.Fatalf("unexpected page meta: %+v", meta)
}
}
+17 -58
View File
@@ -1,11 +1,13 @@
package response package response
import ( import (
"errors"
"net/http" "net/http"
"strconv"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
orm "tm/pkg/mongo"
) )
// APIResponse represents a standard API response structure // APIResponse represents a standard API response structure
@@ -26,17 +28,17 @@ type APIError struct {
// Meta represents metadata for paginated responses // Meta represents metadata for paginated responses
type Meta struct { type Meta struct {
Total int `json:"total,omitempty"` Total int `json:"total"`
Limit int `json:"limit"` Limit int `json:"limit"`
Offset int `json:"offset"` Offset int `json:"offset"`
Page int `json:"page,omitempty"` Page int `json:"page"`
Pages int `json:"pages,omitempty"` Pages int `json:"pages"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor,omitempty"` NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more,omitempty"`
} }
type Pagination struct { type Pagination struct {
Limit int `query:"limit" valid:"optional,range(1|100)" default:"20"` Limit int `query:"limit" valid:"optional,range(1|100)" default:"10"`
Offset int `query:"offset" valid:"optional,range(0|1000000)" default:"0"` Offset int `query:"offset" valid:"optional,range(0|1000000)" default:"0"`
Cursor string `query:"cursor" valid:"optional"` Cursor string `query:"cursor" valid:"optional"`
SortBy string `query:"sort_by" valid:"optional"` SortBy string `query:"sort_by" valid:"optional"`
@@ -192,59 +194,16 @@ func Parse[T any](c echo.Context) (*T, error) {
return &form, nil return &form, nil
} }
func NewPagination(c echo.Context) *Pagination { // PaginationBadRequest writes a 400 response for invalid list pagination parameters.
limit, err := strconv.Atoi(c.QueryParam("limit")) func PaginationBadRequest(c echo.Context, err error) error {
if err != nil { msg := "Invalid pagination parameters"
limit = 20 if errors.Is(err, orm.ErrInvalidCursor) {
} msg = "Invalid pagination cursor"
offset, err := strconv.Atoi(c.QueryParam("offset"))
if err != nil {
offset = 0
}
sortBy := c.QueryParam("sort_by")
if sortBy == "" {
sortBy = "created_at"
}
sortOrder := c.QueryParam("sort_order")
if sortOrder == "" {
sortOrder = "desc"
}
cursor := c.QueryParam("cursor")
return &Pagination{
Limit: limit,
Offset: offset,
Cursor: cursor,
SortBy: sortBy,
SortOrder: sortOrder,
} }
return BadRequest(c, msg, err.Error())
} }
// Response calculates offset-based pagination metadata. // IsListPaginationError reports client-facing pagination errors from list queries.
func (p *Pagination) Response(total int64) *Meta { func IsListPaginationError(err error) bool {
return p.ListMeta(total, "", false) return IsPaginationError(err) || errors.Is(err, orm.ErrInvalidCursor) || errors.Is(err, orm.ErrInvalidPagination)
}
// ListMeta builds pagination metadata for list endpoints (offset and/or cursor).
// When total is negative, total/page/pages are omitted (count was skipped).
func (p *Pagination) ListMeta(total int64, nextCursor string, hasMore bool) *Meta {
meta := &Meta{
Limit: p.Limit,
Offset: p.Offset,
NextCursor: nextCursor,
HasMore: hasMore,
}
if total >= 0 {
pages := (total + int64(p.Limit) - 1) / int64(p.Limit)
meta.Total = int(total)
if p.Limit > 0 {
meta.Page = p.Offset/p.Limit + 1
}
meta.Pages = int(pages)
}
return meta
} }