Subject: [PATCH links] Fixing "wonky" previous (back) pagination issues.

From: Peter Sanchez <[email-masked]>
Date: Thu, 3 Apr 2025 07:27:41 -0600

Created generic functions to query correctly and also paginate correctly
given the pagination needs and requirements for this project.

Fixes: https://todo.code.netlandish.com/~netlandish/links/102
Changelog-added: Generic functions to fetch data and paginate correctly
  so all views that need pagination can easily plug it in.
---
This should resolve the weirdness when paginating via "prev" links
throughout the site. Also there was a lot of inconsistencies in the
pagination. Sometimes the ordering was wrong on display even though the
data was returned correctly (needed reversing, etc.). I've made it all
uniform and use the same generic functions to keep everything in sync.

Some more tweaks are probably needed in the future to customize sort
ordering, etc.

 admin/routes.go               |  15 +
 api/graph/pagination.go       |  81 +++
 api/graph/pagination_test.go  |  77 +++
 api/graph/schema.resolvers.go | 942 ++++++----------------------------
 billing/routes.go             |   5 +
 core/routes.go                |   8 +
 list/routes.go                |   8 +
 mattermost/routes.go          |   6 -
 short/routes.go               |   4 +
 9 files changed, 354 insertions(+), 792 deletions(-)
 create mode 100644 api/graph/pagination.go
 create mode 100644 api/graph/pagination_test.go

diff --git a/admin/routes.go b/admin/routes.go
index 1e730b1..d6f30a8 100644
--- a/admin/routes.go
+++ b/admin/routes.go
@@ -7,6 +7,7 @@ import (
 	"links/internal/localizer"
 	"links/models"
 	"net/http"
+	"slices"
 	"strconv"
 	"strings"
 	"time"
@@ -446,6 +447,10 @@ func (s *Service) OrgDetail(c echo.Context) error {
 		return err
 	}
 
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(historyResult.Payments.Result)
+	}
+
 	type GraphQLStats struct {
 		Stats struct {
 			Links  int `json:"links"`
@@ -1005,6 +1010,10 @@ func (s *Service) BillingList(c echo.Context) error {
 		return err
 	}
 
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(historyResult.Payments.Result)
+	}
+
 	gmap := gobwebs.Map{
 		"pd":               pd,
 		"navFlag":          "admin",
@@ -1164,6 +1173,9 @@ func (s *Service) DomainList(c echo.Context) error {
 	if err != nil {
 		return err
 	}
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(result.Organizations.Result)
+	}
 	if result.Organizations.PageInfo.HasPrevPage {
 		gmap["prevURL"] = links.GetPaginationParams(c, "prev", result.Organizations.PageInfo.Cursor, "next")
 	}
@@ -1630,6 +1642,9 @@ func (s *Service) UserList(c echo.Context) error {
 	if err != nil {
 		return err
 	}
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(result.GetUsers.Result)
+	}
 	if result.GetUsers.PageInfo.HasPrevPage {
 		gmap["prevURL"] = links.GetPaginationParams(c, "prev", result.GetUsers.PageInfo.Cursor, "next")
 	}
diff --git a/api/graph/pagination.go b/api/graph/pagination.go
new file mode 100644
index 0000000..82043a4
--- /dev/null
+++ b/api/graph/pagination.go
@@ -0,0 +1,81 @@
+package graph
+
+import (
+	"context"
+	"links/api/graph/model"
+
+	sq "github.com/Masterminds/squirrel"
+	"netlandish.com/x/gobwebs/database"
+)
+
+func PaginateResults[T any](items []T, limit int, before, after *model.Cursor,
+	getID func(T) int) ([]T, *model.PageInfo) {
+	overFetched := len(items) > limit
+	if overFetched {
+		items = items[:limit]
+	}
+
+	var pageInfo model.PageInfo
+	if len(items) > 0 {
+		pageInfo.Cursor.Limit = limit
+		pageInfo.Cursor.Before = getID(items[0])
+		pageInfo.Cursor.After = getID(items[len(items)-1])
+	}
+
+	if before != nil {
+		pageInfo.HasPrevPage = overFetched
+		pageInfo.HasNextPage = true
+		pageInfo.Cursor.Before, pageInfo.Cursor.After =
+			pageInfo.Cursor.After, pageInfo.Cursor.Before
+	} else if after != nil {
+		pageInfo.HasPrevPage = true
+		pageInfo.HasNextPage = overFetched
+	} else {
+		pageInfo.HasNextPage = overFetched
+	}
+
+	return items, &pageInfo
+}
+
+func QueryModel[T any](
+	ctx context.Context,
+	opts *database.FilterOptions,
+	idField string,
+	limit *int,
+	before, after *model.Cursor,
+	getModels func(context.Context, *database.FilterOptions) ([]T, error),
+	getID func(T) int,
+) ([]T, *model.PageInfo, error) {
+	numElements := model.PaginationDefault
+	if after != nil {
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Lt{idField: after.After},
+		}
+		numElements = after.Limit
+	} else if before != nil {
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Gt{idField: before.Before},
+		}
+		opts.OrderBy = idField + " ASC"
+		numElements = before.Limit
+	}
+
+	// If limit specifically set, it overrides any cursor limit
+	if limit != nil && *limit > 0 {
+		numElements = *limit
+	}
+	if numElements > model.PaginationMax {
+		numElements = model.PaginationMax
+	}
+
+	opts.Limit = numElements + 1
+	items, err := getModels(ctx, opts)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	items, pageInfo := PaginateResults(items, numElements, before, after, getID)
+	return items, pageInfo, nil
+}
diff --git a/api/graph/pagination_test.go b/api/graph/pagination_test.go
new file mode 100644
index 0000000..82e7500
--- /dev/null
+++ b/api/graph/pagination_test.go
@@ -0,0 +1,77 @@
+package graph
+
+import (
+	"links/api/graph/model"
+	"slices"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestPaginationForwardAndBackward(t *testing.T) {
+	var allItems []int
+	for i := 126; i >= 101; i-- {
+		allItems = append(allItems, i)
+	}
+
+	getPage := func(after, before *model.Cursor) ([]int, *model.PageInfo) {
+		var filtered []int
+		for _, id := range allItems {
+			// ORDER BY id DESC
+			if after != nil && id >= after.After {
+				continue
+			}
+			// ORDER BY id ASC
+			if before != nil && id <= before.Before {
+				continue
+			}
+			filtered = append(filtered, id)
+		}
+		if before != nil {
+			slices.Reverse(filtered)
+		}
+		return PaginateResults(filtered, 5, before, after, func(v int) int {
+			return v
+		})
+	}
+
+	t.Run("Forward Pagination", func(t *testing.T) {
+		var after *model.Cursor
+		seen := make(map[int]bool)
+		for {
+			page, info := getPage(after, nil)
+			// t.Logf("Backward Page: %v", page)
+			for _, id := range page {
+				if seen[id] {
+					t.Fatalf("duplicate forward id %d", id)
+				}
+				seen[id] = true
+			}
+			if !info.HasNextPage {
+				break
+			}
+			after = &model.Cursor{After: info.Cursor.After, Limit: 5}
+		}
+		assert.Equal(t, 26, len(seen))
+	})
+
+	t.Run("Backward Pagination", func(t *testing.T) {
+		before := &model.Cursor{Before: 0}
+		seen := make(map[int]bool)
+		for {
+			page, info := getPage(nil, before)
+			// t.Logf("Backward Page: %v", page)
+			for _, id := range page {
+				if seen[id] {
+					t.Fatalf("duplicate backward id %d", id)
+				}
+				seen[id] = true
+			}
+			if !info.HasPrevPage {
+				break
+			}
+			before = &model.Cursor{Before: info.Cursor.Before, Limit: 5}
+		}
+		assert.Equal(t, 26, len(seen))
+	})
+}
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index a00cc26..78e264e 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -27,6 +27,7 @@ import (
 	"net/url"
 	"os"
 	"regexp"
+	"slices"
 	"strconv"
 	"strings"
 	"time"
@@ -53,7 +54,7 @@ import (
 )
 
 // Metadata is the resolver for the metadata field.
-func (r *auditLogResolver) Metadata(ctx context.Context, obj *auditlog.AuditLog) (map[string]interface{}, error) {
+func (r *auditLogResolver) Metadata(ctx context.Context, obj *auditlog.AuditLog) (map[string]any, error) {
 	return obj.Metadata, nil
 }
 
@@ -1832,11 +1833,8 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input *model.Profi
 		WithCode(valid.ErrValidationCode)
 
 	var validTZ bool
-	for _, tz := range timezone.GetTZList() {
-		if input.Timezone == tz {
-			validTZ = true
-			break
-		}
+	if slices.Contains(timezone.GetTZList(), input.Timezone) {
+		validTZ = true
 	}
 
 	validator.Expect(validTZ, "%s", lt.Translate("Timezone is invalid")).
@@ -1848,11 +1846,8 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input *model.Profi
 		WithCode(valid.ErrValidationCode)
 
 	var validLang bool
-	for _, lang := range localizer.GetLangList() {
-		if input.DefaultLang == lang {
-			validLang = true
-			break
-		}
+	if slices.Contains(localizer.GetLangList(), input.DefaultLang) {
+		validLang = true
 	}
 
 	validator.Expect(validLang, "%s", lt.Translate("Lang is invalid")).
@@ -4886,10 +4881,6 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP
 	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
 	lt := localizer.GetLocalizer(lang)
 
-	opts := &database.FilterOptions{
-		Filter: sq.And{}, Limit: 1,
-	}
-
 	validator := valid.New(ctx)
 	// If you are not super user we have to validate
 	// org permission to see the payment history
@@ -4899,6 +4890,17 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP
 		return nil, nil
 	}
 
+	if input.After != nil && input.Before != nil {
+		validator.Error("%s", lt.Translate("You can not send both after and before cursors")).
+			WithCode(valid.ErrValidationGlobalCode)
+		return nil, nil
+	}
+
+	opts := &database.FilterOptions{
+		Filter: sq.And{},
+		Limit:  1,
+	}
+
 	var org *models.Organization
 	if input.OrgSlug != nil {
 		opts.Filter = sq.And{
@@ -4926,7 +4928,7 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP
 
 	opts = &database.FilterOptions{
 		Filter:  sq.And{},
-		OrderBy: "i.created_on DESC",
+		OrderBy: "i.id DESC",
 	}
 
 	if org != nil {
@@ -4983,91 +4985,22 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP
 		}
 	}
 
-	// Pagination
-	if input.After != nil && input.Before != nil {
-		validator.Error("%s", lt.Translate("You can not send both after and before cursors")).
-			WithCode(valid.ErrValidationGlobalCode)
-		return nil, nil
-	}
-	numElements := model.PaginationDefault
-	var hasPrevPage bool
-	var hasNextPage bool
-	if input.After != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.LtOrEq{"i.id": input.After.After},
-		}
-		numElements = input.After.Limit
-	} else if input.Before != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.GtOrEq{"i.id": input.Before.Before},
-		}
-		opts.OrderBy = "i.id ASC"
-		numElements = input.Before.Limit
-	}
-	// If limit specifically set, it overrides any cursor limit
-	if input.Limit != nil && *input.Limit > 0 {
-		numElements = *input.Limit
-	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
-	}
-
-	opts.Limit = numElements + 2
-	invoices, err := models.GetInvoices(ctx, opts)
+	invoices, pageInfo, err := QueryModel(
+		ctx,
+		opts,
+		"i.id",
+		input.Limit,
+		input.Before,
+		input.After,
+		models.GetInvoices,
+		func(x *models.Invoice) int {
+			return x.ID
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
 
-	c := model.Cursor{Limit: numElements}
-	count := len(invoices)
-	if count > 0 {
-		// Checking for previous page
-		if input.Before != nil {
-			if int(invoices[0].ID) == input.Before.Before {
-				hasPrevPage = true
-				invoices = invoices[1:]
-				count--
-			}
-		} else if input.After != nil {
-			if int(invoices[0].ID) == input.After.After {
-				hasPrevPage = true
-				invoices = invoices[1:]
-				count--
-			}
-		}
-		if count == opts.Limit {
-			// No previous page
-			invoices = invoices[:count-1]
-			count--
-		}
-
-		if count > numElements {
-			hasNextPage = true
-			invoices = invoices[:count-1]
-			count--
-		}
-		if count > 0 {
-			if input.Before != nil {
-				tmp := hasPrevPage
-				hasPrevPage = hasNextPage
-				hasNextPage = tmp
-				c.After = int(invoices[0].ID)
-				c.Before = int(invoices[count-1].ID)
-			} else {
-				c.After = int(invoices[count-1].ID)
-				c.Before = int(invoices[0].ID)
-			}
-		} else {
-			hasPrevPage = false
-		}
-	}
-	pageInfo := &model.PageInfo{
-		Cursor:      c,
-		HasNextPage: hasNextPage,
-		HasPrevPage: hasPrevPage,
-	}
 	// Payment is a wrap for invoice aimed
 	// to expose only the required fields for payment history
 	var payments []*model.Payment
@@ -5278,33 +5211,18 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp
 
 	}
 
-	numElements := model.PaginationDefault
-	var hasPrevPage bool
-	var hasNextPage bool
-	if input.After != nil {
-		linkOpts.Filter = sq.And{
-			linkOpts.Filter,
-			sq.Expr("ol.id <= ?", input.After.After),
-		}
-		numElements = input.After.Limit
-	} else if input.Before != nil {
-		linkOpts.Filter = sq.And{
-			linkOpts.Filter,
-			sq.Expr("ol.id >= ?", input.Before.Before),
-		}
-		numElements = input.Before.Limit
-	}
-
-	// If limit specifically set, it overrides any cursor limit
-	if input.Limit != nil && *input.Limit > 0 {
-		numElements = *input.Limit
-	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
-	}
-
-	linkOpts.Limit = numElements + 2
-	orgLinks, err := models.GetOrgLinks(ctx, linkOpts)
+	orgLinks, pageInfo, err := QueryModel(
+		ctx,
+		linkOpts,
+		"ol.id",
+		input.Limit,
+		input.Before,
+		input.After,
+		models.GetOrgLinks,
+		func(link *models.OrgLink) int {
+			return link.ID
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
@@ -5318,55 +5236,11 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp
 		}
 	}
 
-	c := model.Cursor{Limit: numElements}
-	count := len(orgLinks)
-	if count > 0 {
-		// Checking for previous page
-		if input.Before != nil {
-			if orgLinks[0].ID == input.Before.Before {
-				hasPrevPage = true
-				orgLinks = orgLinks[1:]
-				count--
-			}
-		} else if input.After != nil {
-			if orgLinks[0].ID == input.After.After {
-				hasPrevPage = true
-				orgLinks = orgLinks[1:]
-				count--
-			}
-		}
-		if count == linkOpts.Limit {
-			// No previous page
-			orgLinks = orgLinks[:count-1]
-			count--
-		}
-
-		if count > numElements {
-			hasNextPage = true
-			orgLinks = orgLinks[:count-1]
-			count--
-		}
-		if count > 0 {
-			if input.Before != nil {
-				tmp := hasPrevPage
-				hasPrevPage = hasNextPage
-				hasNextPage = tmp
-				c.After = orgLinks[0].ID
-				c.Before = orgLinks[count-1].ID
-			} else {
-				c.After = orgLinks[count-1].ID
-				c.Before = orgLinks[0].ID
-			}
-		} else {
-			hasPrevPage = false
-		}
-	}
-	pageInfo := &model.PageInfo{
-		Cursor:      c,
-		HasNextPage: hasNextPage,
-		HasPrevPage: hasPrevPage,
-	}
-	return &model.OrgLinkCursor{Result: orgLinks, PageInfo: pageInfo, RestrictedCount: &resCount}, nil
+	return &model.OrgLinkCursor{
+		Result:          orgLinks,
+		PageInfo:        pageInfo,
+		RestrictedCount: &resCount,
+	}, nil
 }
 
 // GetOrgMembers is the resolver for the getOrgMembers field.
@@ -5579,7 +5453,7 @@ func (r *queryResolver) GetLinkShorts(ctx context.Context, input *model.GetLinkS
 		Filter: sq.And{
 			sq.Expr("l.org_id = ?", org.ID),
 		},
-		OrderBy: "id DESC",
+		OrderBy: "l.id DESC",
 	}
 	if input.After != nil && input.Before != nil {
 		validator.Error("%s", lt.Translate("You can not send both after and before cursors")).
@@ -5599,86 +5473,22 @@ func (r *queryResolver) GetLinkShorts(ctx context.Context, input *model.GetLinkS
 		}
 	}
 
-	numElements := model.PaginationDefault
-	var hasPrevPage bool
-	var hasNextPage bool
-	if input.After != nil {
-		linkOpts.Filter = sq.And{
-			linkOpts.Filter,
-			sq.Expr("l.id <= ?", input.After.After),
-		}
-		numElements = input.After.Limit
-	} else if input.Before != nil {
-		linkOpts.Filter = sq.And{
-			linkOpts.Filter,
-			sq.Expr("l.id >= ?", input.Before.Before),
-		}
-		linkOpts.OrderBy = "l.id ASC"
-		numElements = input.Before.Limit
-	}
-
-	// If limit specifically set, it overrides any cursor limit
-	if input.Limit != nil && *input.Limit > 0 {
-		numElements = *input.Limit
-	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
-	}
-
-	linkOpts.Limit = numElements + 2
-	linkShorts, err := models.GetLinkShorts(ctx, linkOpts)
+	linkShorts, pageInfo, err := QueryModel(
+		ctx,
+		linkOpts,
+		"l.id",
+		input.Limit,
+		input.Before,
+		input.After,
+		models.GetLinkShorts,
+		func(x *models.LinkShort) int {
+			return x.ID
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
 
-	c := model.Cursor{Limit: numElements}
-	count := len(linkShorts)
-	if count > 0 {
-		// Checking for previous page
-		if input.Before != nil {
-			if linkShorts[0].ID == input.Before.Before {
-				hasPrevPage = true
-				linkShorts = linkShorts[1:]
-				count--
-			}
-		} else if input.After != nil {
-			if linkShorts[0].ID == input.After.After {
-				hasPrevPage = true
-				linkShorts = linkShorts[1:]
-				count--
-			}
-		}
-		if count == linkOpts.Limit {
-			// No previous page
-			linkShorts = linkShorts[:count-1]
-			count--
-		}
-
-		if count > numElements {
-			hasNextPage = true
-			linkShorts = linkShorts[:count-1]
-			count--
-		}
-		if count > 0 {
-			if input.Before != nil {
-				tmp := hasPrevPage
-				hasPrevPage = hasNextPage
-				hasNextPage = tmp
-				c.After = linkShorts[0].ID
-				c.Before = linkShorts[count-1].ID
-			} else {
-				c.After = linkShorts[count-1].ID
-				c.Before = linkShorts[0].ID
-			}
-		} else {
-			hasPrevPage = false
-		}
-	}
-	pageInfo := &model.PageInfo{
-		Cursor:      c,
-		HasNextPage: hasNextPage,
-		HasPrevPage: hasPrevPage,
-	}
 	return &model.LinkShortCursor{Result: linkShorts, PageInfo: pageInfo}, nil
 }
 
@@ -5786,85 +5596,22 @@ func (r *queryResolver) GetListings(ctx context.Context, input *model.GetListing
 		}
 	}
 
-	numElements := 10
-	var hasPrevPage bool
-	var hasNextPage bool
-	if input.After != nil {
-		listingOpts.Filter = sq.And{
-			listingOpts.Filter,
-			sq.Expr("l.id <= ?", input.After.After),
-		}
-		numElements = input.After.Limit
-	} else if input.Before != nil {
-		listingOpts.Filter = sq.And{
-			listingOpts.Filter,
-			sq.Expr("l.id >= ?", input.Before.Before),
-		}
-		listingOpts.OrderBy = "l.id ASC"
-		numElements = input.Before.Limit
-	}
-
-	// If limit specifically set, it overrides any cursor limit
-	if input.Limit != nil && *input.Limit > 0 {
-		numElements = *input.Limit
-	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
-	}
-
-	listingOpts.Limit = numElements + 2
-	listings, err := models.GetListings(ctx, listingOpts)
+	listings, pageInfo, err := QueryModel(
+		ctx,
+		listingOpts,
+		"l.id",
+		input.Limit,
+		input.Before,
+		input.After,
+		models.GetListings,
+		func(x *models.Listing) int {
+			return x.ID
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
-	c := model.Cursor{Limit: numElements}
-	count := len(listings)
-	if count > 0 {
-		// Checking for previous page
-		if input.Before != nil {
-			if listings[0].ID == input.Before.Before {
-				hasPrevPage = true
-				listings = listings[1:]
-				count--
-			}
-		} else if input.After != nil {
-			if listings[0].ID == input.After.After {
-				hasPrevPage = true
-				listings = listings[1:]
-				count--
-			}
-		}
-		if count == listingOpts.Limit {
-			// No previous page
-			listings = listings[:count-1]
-			count--
-		}
 
-		if count > numElements {
-			hasNextPage = true
-			listings = listings[:count-1]
-			count--
-		}
-		if count > 0 {
-			if input.Before != nil {
-				tmp := hasPrevPage
-				hasPrevPage = hasNextPage
-				hasNextPage = tmp
-				c.After = listings[0].ID
-				c.Before = listings[count-1].ID
-			} else {
-				c.After = listings[count-1].ID
-				c.Before = listings[0].ID
-			}
-		} else {
-			hasPrevPage = false
-		}
-	}
-	pageInfo := &model.PageInfo{
-		Cursor:      c,
-		HasNextPage: hasNextPage,
-		HasPrevPage: hasPrevPage,
-	}
 	return &model.ListingCursor{Result: listings, PageInfo: pageInfo}, nil
 }
 
@@ -5933,85 +5680,22 @@ func (r *queryResolver) GetListing(ctx context.Context, input *model.GetListingD
 		OrderBy: "ll.link_order ASC",
 	}
 
-	numElements := 10
-	var hasPrevPage bool
-	var hasNextPage bool
-	if input.After != nil {
-		linkOpts.Filter = sq.And{
-			linkOpts.Filter,
-			sq.LtOrEq{"ll.id": input.After.After},
-		}
-		numElements = input.After.Limit
-	} else if input.Before != nil {
-		linkOpts.Filter = sq.And{
-			linkOpts.Filter,
-			sq.GtOrEq{"ll.id": input.Before.Before},
-		}
-		linkOpts.OrderBy = "ll.id ASC"
-		numElements = input.Before.Limit
-	}
-
-	// If limit specifically set, it overrides any cursor limit
-	if input.Limit != nil && *input.Limit > 0 {
-		numElements = *input.Limit
-	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
-	}
-
-	linkOpts.Limit = numElements + 2
-	listingLinks, err := models.GetListingLinks(ctx, linkOpts)
+	listingLinks, pageInfo, err := QueryModel(
+		ctx,
+		linkOpts,
+		"ll.link_order",
+		input.Limit,
+		input.Before,
+		input.After,
+		models.GetListingLinks,
+		func(x *models.ListingLink) int {
+			return x.ID
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
-	c := model.Cursor{Limit: numElements}
-	count := len(listingLinks)
-	if count > 0 {
-		// Checking for previous page
-		if input.Before != nil {
-			if listingLinks[0].ID == input.Before.Before {
-				hasPrevPage = true
-				listingLinks = listingLinks[1:]
-				count--
-			}
-		} else if input.After != nil {
-			if listingLinks[0].ID == input.After.After {
-				hasPrevPage = true
-				listingLinks = listingLinks[1:]
-				count--
-			}
-		}
-		if count == linkOpts.Limit {
-			// No previous page
-			listingLinks = listingLinks[:count-1]
-			count--
-		}
 
-		if count > numElements {
-			hasNextPage = true
-			listingLinks = listingLinks[:count-1]
-			count--
-		}
-		if count > 0 {
-			if input.Before != nil {
-				tmp := hasPrevPage
-				hasPrevPage = hasNextPage
-				hasNextPage = tmp
-				c.After = listingLinks[0].ID
-				c.Before = listingLinks[count-1].ID
-			} else {
-				c.After = listingLinks[count-1].ID
-				c.Before = listingLinks[0].ID
-			}
-		} else {
-			hasPrevPage = false
-		}
-	}
-	pageInfo := &model.PageInfo{
-		Cursor:      c,
-		HasNextPage: hasNextPage,
-		HasPrevPage: hasPrevPage,
-	}
 	return &model.ListingLinkCursor{Result: listing, Links: listingLinks, PageInfo: pageInfo}, nil
 }
 
@@ -6530,6 +6214,12 @@ func (r *queryResolver) GetFeed(ctx context.Context, input *model.GetFeedInput)
 	ctx = timezone.Context(ctx, links.GetUserTZ(user))
 	validator := valid.New(ctx)
 
+	if input.After != nil && input.Before != nil {
+		validator.Error("%s", lt.Translate("You can not send both after and before cursors")).
+			WithCode(valid.ErrValidationGlobalCode)
+		return nil, nil
+	}
+
 	linkOpts := &database.FilterOptions{
 		Filter: sq.Or{
 			// An user follows another user
@@ -6569,92 +6259,22 @@ func (r *queryResolver) GetFeed(ctx context.Context, input *model.GetFeedInput)
 
 	}
 
-	if input.After != nil && input.Before != nil {
-		validator.Error("%s", lt.Translate("You can not send both after and before cursors")).
-			WithCode(valid.ErrValidationGlobalCode)
-		return nil, nil
-	}
-
-	numElements := model.PaginationDefault
-	var hasPrevPage bool
-	var hasNextPage bool
-	if input.After != nil {
-		linkOpts.Filter = sq.And{
-			linkOpts.Filter,
-			sq.Expr("ol.id <= ?", input.After.After),
-		}
-		numElements = input.After.Limit
-	} else if input.Before != nil {
-		linkOpts.Filter = sq.And{
-			linkOpts.Filter,
-			sq.Expr("ol.id >= ?", input.Before.Before),
-		}
-		linkOpts.OrderBy = "ol.id ASC"
-		numElements = input.Before.Limit
-	}
-
-	// If limit specifically set, it overrides any cursor limit
-	if input.Limit != nil && *input.Limit > 0 {
-		numElements = *input.Limit
-	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
-	}
-
-	linkOpts.Limit = numElements + 2
-	orgLinks, err := models.GetOrgLinks(ctx, linkOpts)
+	orgLinks, pageInfo, err := QueryModel(
+		ctx,
+		linkOpts,
+		"ol.id",
+		input.Limit,
+		input.Before,
+		input.After,
+		models.GetOrgLinks,
+		func(x *models.OrgLink) int {
+			return x.ID
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
 
-	c := model.Cursor{Limit: numElements}
-	count := len(orgLinks)
-	if count > 0 {
-		// Checking for previous page
-		if input.Before != nil {
-			if orgLinks[0].ID == input.Before.Before {
-				hasPrevPage = true
-				orgLinks = orgLinks[1:]
-				count--
-			}
-		} else if input.After != nil {
-			if orgLinks[0].ID == input.After.After {
-				hasPrevPage = true
-				orgLinks = orgLinks[1:]
-				count--
-			}
-		}
-		if count == linkOpts.Limit {
-			// No previous page
-			orgLinks = orgLinks[:count-1]
-			count--
-		}
-
-		if count > numElements {
-			hasNextPage = true
-			orgLinks = orgLinks[:count-1]
-			count--
-		}
-		if count > 0 {
-			if input.Before != nil {
-				tmp := hasPrevPage
-				hasPrevPage = hasNextPage
-				hasNextPage = tmp
-				c.After = orgLinks[0].ID
-				c.Before = orgLinks[count-1].ID
-			} else {
-				c.After = orgLinks[count-1].ID
-				c.Before = orgLinks[0].ID
-			}
-		} else {
-			hasPrevPage = false
-		}
-	}
-	pageInfo := &model.PageInfo{
-		Cursor:      c,
-		HasNextPage: hasNextPage,
-		HasPrevPage: hasPrevPage,
-	}
 	return &model.OrgLinkCursor{Result: orgLinks, PageInfo: pageInfo}, nil
 }
 
@@ -6814,86 +6434,22 @@ func (r *queryResolver) GetAuditLogs(ctx context.Context, input *model.AuditLogI
 		}
 	}
 
-	numElements := model.PaginationDefault
-	var hasPrevPage, hasNextPage bool
-	if input.After != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.Expr("al.id <= ?", input.After.After),
-		}
-		numElements = input.After.Limit
-	} else if input.Before != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.Expr("al.id >= ?", input.Before.Before),
-		}
-		opts.OrderBy = "al.id ASC"
-		numElements = input.Before.Limit
-	}
-
-	// If limit specifically set, it overrides any cursor limit
-	if input.Limit != nil && *input.Limit > 0 {
-		numElements = *input.Limit
-	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
-	}
-	opts.Limit = numElements + 2
-
-	alogs, err := auditlog.GetAuditLogs(ctx, opts)
+	alogs, pageInfo, err := QueryModel(
+		ctx,
+		opts,
+		"al.id",
+		input.Limit,
+		input.Before,
+		input.After,
+		auditlog.GetAuditLogs,
+		func(x *auditlog.AuditLog) int {
+			return x.ID
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
 
-	c := model.Cursor{Limit: numElements}
-	count := len(alogs)
-	if count > 0 {
-		// Checking for previous page
-		if input.Before != nil {
-			if alogs[0].ID == input.Before.Before {
-				hasPrevPage = true
-				alogs = alogs[1:]
-				count--
-			}
-		} else if input.After != nil {
-			if alogs[0].ID == input.After.After {
-				hasPrevPage = true
-				alogs = alogs[1:]
-				count--
-			}
-		}
-		if count == opts.Limit {
-			// No previous page
-			alogs = alogs[:count-1]
-			count--
-		}
-
-		if count > numElements {
-			hasNextPage = true
-			alogs = alogs[:count-1]
-			count--
-		}
-		if count > 0 {
-			if input.Before != nil {
-				tmp := hasPrevPage
-				hasPrevPage = hasNextPage
-				hasNextPage = tmp
-				c.After = alogs[0].ID
-				c.Before = alogs[count-1].ID
-			} else {
-				c.After = alogs[count-1].ID
-				c.Before = alogs[0].ID
-			}
-		} else {
-			hasPrevPage = false
-		}
-	}
-
-	pageInfo := &model.PageInfo{
-		Cursor:      c,
-		HasNextPage: hasNextPage,
-		HasPrevPage: hasPrevPage,
-	}
 	return &model.AuditLogCursor{Result: alogs, PageInfo: pageInfo}, nil
 }
 
@@ -6941,84 +6497,22 @@ func (r *queryResolver) GetUsers(ctx context.Context, input *model.GetUserInput)
 
 	}
 
-	numElements := model.PaginationDefault
-	var hasPrevPage bool
-	var hasNextPage bool
-	if input.After != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.LtOrEq{"u.id": input.After.After},
-		}
-		numElements = input.After.Limit
-	} else if input.Before != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.GtOrEq{"u.id": input.Before.Before},
-		}
-		opts.OrderBy = "u.id ASC"
-		numElements = input.Before.Limit
-	}
-	// If limit specifically set, it overrides any cursor limit
-	if input.Limit != nil && *input.Limit > 0 {
-		numElements = *input.Limit
-	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
-	}
-
-	opts.Limit = numElements + 2
-	users, err := models.GetUsers(ctx, opts)
+	users, pageInfo, err := QueryModel(
+		ctx,
+		opts,
+		"u.id",
+		input.Limit,
+		input.Before,
+		input.After,
+		models.GetUsers,
+		func(x *models.User) int {
+			return int(x.ID)
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
-	c := model.Cursor{Limit: numElements}
-	count := len(users)
-	if count > 0 {
-		// Checking for previous page
-		if input.Before != nil {
-			if int(users[0].ID) == input.Before.Before {
-				hasPrevPage = true
-				users = users[1:]
-				count--
-			}
-		} else if input.After != nil {
-			if int(users[0].ID) == input.After.After {
-				hasPrevPage = true
-				users = users[1:]
-				count--
-			}
-		}
-		if count == opts.Limit {
-			// No previous page
-			users = users[:count-1]
-			count--
-		}
 
-		if count > numElements {
-			hasNextPage = true
-			users = users[:count-1]
-			count--
-		}
-		if count > 0 {
-			if input.Before != nil {
-				tmp := hasPrevPage
-				hasPrevPage = hasNextPage
-				hasNextPage = tmp
-				c.After = int(users[0].ID)
-				c.Before = int(users[count-1].ID)
-			} else {
-				c.After = int(users[count-1].ID)
-				c.Before = int(users[0].ID)
-			}
-		} else {
-			hasPrevPage = false
-		}
-	}
-	pageInfo := &model.PageInfo{
-		Cursor:      c,
-		HasNextPage: hasNextPage,
-		HasPrevPage: hasPrevPage,
-	}
 	return &model.UserCursor{Result: users, PageInfo: pageInfo}, nil
 }
 
@@ -7094,84 +6588,22 @@ func (r *queryResolver) GetAdminOrganizations(ctx context.Context, input *model.
 		}
 	}
 
-	numElements := model.PaginationDefault
-	var hasPrevPage bool
-	var hasNextPage bool
-	if input.After != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.LtOrEq{"o.id": input.After.After},
-		}
-		numElements = input.After.Limit
-	} else if input.Before != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.GtOrEq{"o.id": input.Before.Before},
-		}
-		opts.OrderBy = "o.id ASC"
-		numElements = input.Before.Limit
-	}
-	// If limit specifically set, it overrides any cursor limit
-	if input.Limit != nil && *input.Limit > 0 {
-		numElements = *input.Limit
-	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
-	}
-
-	opts.Limit = numElements + 2
-	orgs, err := models.GetOrganizations(ctx, opts)
+	orgs, pageInfo, err := QueryModel(
+		ctx,
+		opts,
+		"o.id",
+		input.Limit,
+		input.Before,
+		input.After,
+		models.GetOrganizations,
+		func(x *models.Organization) int {
+			return x.ID
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
-	c := model.Cursor{Limit: numElements}
-	count := len(orgs)
-	if count > 0 {
-		// Checking for previous page
-		if input.Before != nil {
-			if int(orgs[0].ID) == input.Before.Before {
-				hasPrevPage = true
-				orgs = orgs[1:]
-				count--
-			}
-		} else if input.After != nil {
-			if int(orgs[0].ID) == input.After.After {
-				hasPrevPage = true
-				orgs = orgs[1:]
-				count--
-			}
-		}
-		if count == opts.Limit {
-			// No previous page
-			orgs = orgs[:count-1]
-			count--
-		}
 
-		if count > numElements {
-			hasNextPage = true
-			orgs = orgs[:count-1]
-			count--
-		}
-		if count > 0 {
-			if input.Before != nil {
-				tmp := hasPrevPage
-				hasPrevPage = hasNextPage
-				hasNextPage = tmp
-				c.After = int(orgs[0].ID)
-				c.Before = int(orgs[count-1].ID)
-			} else {
-				c.After = int(orgs[count-1].ID)
-				c.Before = int(orgs[0].ID)
-			}
-		} else {
-			hasPrevPage = false
-		}
-	}
-	pageInfo := &model.PageInfo{
-		Cursor:      c,
-		HasNextPage: hasNextPage,
-		HasPrevPage: hasPrevPage,
-	}
 	return &model.OrganizationCursor{Result: orgs, PageInfo: pageInfo}, nil
 }
 
@@ -7353,6 +6785,12 @@ func (r *queryResolver) GetAdminDomains(ctx context.Context, input *model.GetAdm
 		return nil, nil
 	}
 
+	if input.After != nil && input.Before != nil {
+		validator.Error("%s", lt.Translate("You can not send both after and before cursors")).
+			WithCode(valid.ErrValidationGlobalCode)
+		return nil, nil
+	}
+
 	opts := &database.FilterOptions{
 		Filter:  sq.And{},
 		OrderBy: "d.id DESC",
@@ -7371,12 +6809,6 @@ func (r *queryResolver) GetAdminDomains(ctx context.Context, input *model.GetAdm
 		}
 	}
 
-	if input.After != nil && input.Before != nil {
-		validator.Error("%s", lt.Translate("You can not send both after and before cursors")).
-			WithCode(valid.ErrValidationGlobalCode)
-		return nil, nil
-	}
-
 	if input.FilterLevel != nil {
 		opts.Filter = sq.And{
 			opts.Filter,
@@ -7406,84 +6838,22 @@ func (r *queryResolver) GetAdminDomains(ctx context.Context, input *model.GetAdm
 
 	}
 
-	numElements := model.PaginationDefault
-	var hasPrevPage bool
-	var hasNextPage bool
-	if input.After != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.LtOrEq{"d.id": input.After.After},
-		}
-		numElements = input.After.Limit
-	} else if input.Before != nil {
-		opts.Filter = sq.And{
-			opts.Filter,
-			sq.GtOrEq{"d.id": input.Before.Before},
-		}
-		opts.OrderBy = "d.id ASC"
-		numElements = input.Before.Limit
-	}
-	// If limit specifically set, it overrides any cursor limit
-	if input.Limit != nil && *input.Limit > 0 {
-		numElements = *input.Limit
-	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
-	}
-
-	opts.Limit = numElements + 2
-	domains, err := models.GetDomains(ctx, opts)
+	domains, pageInfo, err := QueryModel(
+		ctx,
+		opts,
+		"d.id",
+		input.Limit,
+		input.Before,
+		input.After,
+		models.GetDomains,
+		func(x *models.Domain) int {
+			return x.ID
+		},
+	)
 	if err != nil {
 		return nil, err
 	}
-	c := model.Cursor{Limit: numElements}
-	count := len(domains)
-	if count > 0 {
-		// Checking for previous page
-		if input.Before != nil {
-			if int(domains[0].ID) == input.Before.Before {
-				hasPrevPage = true
-				domains = domains[1:]
-				count--
-			}
-		} else if input.After != nil {
-			if int(domains[0].ID) == input.After.After {
-				hasPrevPage = true
-				domains = domains[1:]
-				count--
-			}
-		}
-		if count == opts.Limit {
-			// No previous page
-			domains = domains[:count-1]
-			count--
-		}
 
-		if count > numElements {
-			hasNextPage = true
-			domains = domains[:count-1]
-			count--
-		}
-		if count > 0 {
-			if input.Before != nil {
-				tmp := hasPrevPage
-				hasPrevPage = hasNextPage
-				hasNextPage = tmp
-				c.After = int(domains[0].ID)
-				c.Before = int(domains[count-1].ID)
-			} else {
-				c.After = int(domains[count-1].ID)
-				c.Before = int(domains[0].ID)
-			}
-		} else {
-			hasPrevPage = false
-		}
-	}
-	pageInfo := &model.PageInfo{
-		Cursor:      c,
-		HasNextPage: hasNextPage,
-		HasPrevPage: hasPrevPage,
-	}
 	return &model.DomainCursor{Result: domains, PageInfo: pageInfo}, nil
 }
 
diff --git a/billing/routes.go b/billing/routes.go
index 5fd4e83..7849f1e 100644
--- a/billing/routes.go
+++ b/billing/routes.go
@@ -7,6 +7,7 @@ import (
 	"links/internal/localizer"
 	"links/models"
 	"net/http"
+	"slices"
 	"strconv"
 
 	"git.sr.ht/~emersion/gqlclient"
@@ -273,6 +274,10 @@ func (s *Service) SubscriptionHistory(c echo.Context) error {
 		return err
 	}
 
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(result.Payments.Result)
+	}
+
 	gmap := gobwebs.Map{
 		"pd":       pd,
 		"invoices": result.Payments.Result,
diff --git a/core/routes.go b/core/routes.go
index d6bdf55..94a8a23 100644
--- a/core/routes.go
+++ b/core/routes.go
@@ -13,6 +13,7 @@ import (
 	"links/slack"
 	"net/http"
 	"net/url"
+	"slices"
 	"strconv"
 	"strings"
 	"time"
@@ -1835,6 +1836,9 @@ func (s *Service) UserFeed(c echo.Context) error {
 	pd.Data["clear"] = lt.Translate("Clear")
 	pd.Data["followings"] = lt.Translate("Followings")
 	orgLinks := result.OrgLinks.Result
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(orgLinks)
+	}
 
 	if links.IsRSS(c.Path()) {
 		domain := fmt.Sprintf("%s://%s", gctx.Server.Config.Scheme, gctx.Server.Config.Domain)
@@ -2213,6 +2217,10 @@ func (s *Service) OrgLinksList(c echo.Context) error {
 		return links.ServerRSSFeed(c.Response(), rss)
 	}
 
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(orgLinks)
+	}
+
 	seoData := links.GetSEOData(c)
 	url := links.GetLinksDomainURL(c)
 	if isOrgLink {
diff --git a/list/routes.go b/list/routes.go
index 1271fbf..2f82358 100644
--- a/list/routes.go
+++ b/list/routes.go
@@ -11,6 +11,7 @@ import (
 	"links/models"
 	"net/http"
 	"net/url"
+	"slices"
 	"strconv"
 	"strings"
 	"time"
@@ -569,6 +570,10 @@ func (s *Service) ListingLinksManage(c echo.Context) error {
 		return err
 	}
 
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(result.Listing.Links)
+	}
+
 	lt := localizer.GetSessionLocalizer(c)
 	pd := localizer.NewPageData(lt.Translate("%s Links", result.Listing.Result.Title))
 	pd.Data["edit"] = lt.Translate("Edit")
@@ -1106,6 +1111,9 @@ func (s *Service) ListingList(c echo.Context) error {
 		}
 		return err
 	}
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(result.Listings.Result)
+	}
 	gmap := gobwebs.Map{
 		"pd":                pd,
 		"org":               org,
diff --git a/mattermost/routes.go b/mattermost/routes.go
index e9094d0..19ebef1 100644
--- a/mattermost/routes.go
+++ b/mattermost/routes.go
@@ -135,12 +135,6 @@ func (s *Service) Connect(c echo.Context) error {
 		return s.Render(c, http.StatusOK, "connect_mattermost.html", gmap)
 	}
 
-	opts = &database.FilterOptions{
-		Filter: sq.And{
-			sq.Eq{"o.owner_id": user.ID},
-			sq.Eq{"o.is_active": true},
-		},
-	}
 	gmap := gobwebs.Map{
 		"pd":     pd,
 		"org":    org,
diff --git a/short/routes.go b/short/routes.go
index 96d1106..ebbac75 100644
--- a/short/routes.go
+++ b/short/routes.go
@@ -11,6 +11,7 @@ import (
 	"links/models"
 	"net/http"
 	"net/url"
+	"slices"
 	"strconv"
 	"strings"
 	"time"
@@ -167,6 +168,9 @@ func (s *Service) LinkShortList(c echo.Context) error {
 	pd.Data["clear"] = lt.Translate("Clear")
 
 	linkShorts := result.LinkShorts.Result
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(linkShorts)
+	}
 	gmap := gobwebs.Map{
 		"pd":                pd,
 		"links":             linkShorts,
-- 
2.47.2

---

From: "Peter Sanchez" <[email-masked]>
Date: Thu, 03 Apr 2025 07:38:56 -0600

Applied.

To [email-masked]:~netlandish/links
   cbe9bed..9d69e58  master -> master