~netlandish/links-dev

links: Fixing "wonky" previous (back) pagination issues. v1 APPLIED

Peter Sanchez: 1
 Fixing "wonky" previous (back) pagination issues.

 9 files changed, 354 insertions(+), 792 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/122/mbox | git am -3
Learn more about email & git

[PATCH links] Fixing "wonky" previous (back) pagination issues. Export this patch

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
@@ -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
Applied.

To git@git.code.netlandish.com:~netlandish/links
   cbe9bed..9d69e58  master -> master