hybrid pagination
This commit is contained in:
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, ¬ifications); 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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-15
@@ -13,15 +13,17 @@ const pageCursorVersion = 1
|
|||||||
|
|
||||||
// pageCursor is an opaque pagination token for keyset (cursor) pagination.
|
// pageCursor is an opaque pagination token for keyset (cursor) pagination.
|
||||||
type pageCursor struct {
|
type pageCursor struct {
|
||||||
V int `json:"v"`
|
V int `json:"v"`
|
||||||
SortField string `json:"sort_field"`
|
SortField string `json:"sort_field"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -35,22 +37,28 @@ func encodePageCursor(sortField string, sortOrder int, sortValue interface{}, id
|
|||||||
}
|
}
|
||||||
|
|
||||||
payload, err := json.Marshal(pageCursor{
|
payload, err := json.Marshal(pageCursor{
|
||||||
V: pageCursorVersion,
|
V: pageCursorVersion,
|
||||||
SortField: sortField,
|
SortField: sortField,
|
||||||
SortOrder: sortOrder,
|
SortOrder: sortOrder,
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user