Files
descrybe/apps/api/internal/catalog/cursor.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

371 lines
11 KiB
Go

package catalog
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const productCursorVersion = 1
// productCursor is an opaque keyset bookmark for product list pages.
// Encoded as URL-safe base64 JSON in the `cursor` query param.
type productCursor struct {
V int `json:"v"`
ID string `json:"id"`
SB string `json:"sb"`
SO string `json:"so"`
K string `json:"k"`
Blank bool `json:"b,omitempty"`
}
// HasProductCursor reports whether the filter requests keyset pagination.
func HasProductCursor(f ListFilter) bool {
return strings.TrimSpace(f.Cursor) != "" || strings.TrimSpace(f.AfterID) != ""
}
// EncodeProductCursor builds an opaque cursor from a product list row.
func EncodeProductCursor(f ListFilter, item map[string]any) (string, error) {
f = NormalizeListFilter(f)
id := stringifyID(item["id"])
if id == "" {
return "", fmt.Errorf("missing id")
}
cur := productCursor{
V: productCursorVersion,
ID: id,
SB: f.SortBy,
SO: f.SortOrder,
}
switch f.SortBy {
case "name":
name := productSortName(item)
cur.Blank = name == ""
cur.K = strings.ToLower(name)
case "createdAt":
ts, ok := asTime(item["created_at"])
if !ok {
return "", fmt.Errorf("missing created_at")
}
cur.K = ts.UTC().Format(time.RFC3339Nano)
default: // updatedAt
ts, ok := asTime(item["updated_at"])
if !ok {
return "", fmt.Errorf("missing updated_at")
}
cur.K = ts.UTC().Format(time.RFC3339Nano)
}
raw, err := json.Marshal(cur)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
// DecodeProductCursor parses an opaque product list cursor.
func DecodeProductCursor(raw string) (productCursor, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return productCursor{}, fmt.Errorf("empty cursor")
}
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return productCursor{}, fmt.Errorf("invalid cursor encoding")
}
var cur productCursor
if err := json.Unmarshal(b, &cur); err != nil {
return productCursor{}, fmt.Errorf("invalid cursor payload")
}
if cur.V != productCursorVersion {
return productCursor{}, fmt.Errorf("unsupported cursor version")
}
if _, err := uuid.Parse(cur.ID); err != nil {
return productCursor{}, fmt.Errorf("invalid cursor id")
}
switch cur.SB {
case "name", "updatedAt", "createdAt":
default:
return productCursor{}, fmt.Errorf("invalid cursor sort")
}
if cur.SO != "asc" && cur.SO != "desc" {
return productCursor{}, fmt.Errorf("invalid cursor order")
}
return cur, nil
}
// NextProductCursor returns next_cursor / next_after_id when the page is full.
// When the page length equals limit there may still be no further rows (rare);
// clients should treat an empty follow-up page as the end.
func NextProductCursor(f ListFilter, items []map[string]any, limit int) (nextCursor, nextAfterID string) {
f = NormalizeListFilter(f)
if limit <= 0 || len(items) < limit {
return "", ""
}
last := items[len(items)-1]
nextAfterID = stringifyID(last["id"])
enc, err := EncodeProductCursor(f, last)
if err != nil {
return "", nextAfterID
}
return enc, nextAfterID
}
func (c productCursor) matchesFilter(f ListFilter) bool {
f = NormalizeListFilter(f)
return c.SB == f.SortBy && c.SO == f.SortOrder
}
// appendRawKeyset adds a keyset predicate for raw_products (alias rp).
func appendRawKeyset(f ListFilter, cur productCursor, args []any, where []string) ([]any, []string, error) {
id, err := uuid.Parse(cur.ID)
if err != nil {
return args, where, fmt.Errorf("invalid cursor id")
}
dirAfter := keysetOp(f.SortOrder)
switch f.SortBy {
case "name":
nameExpr := `COALESCE(NULLIF(rp.mapped_data->>'name', ''), NULLIF(rp.mapped_data->>'title', ''), '')`
blankExpr := fmt.Sprintf(`(CASE WHEN %s = '' THEN 1 ELSE 0 END)`, nameExpr)
args = append(args, boolToInt(cur.Blank), cur.K, id)
b, k, i := len(args)-2, len(args)-1, len(args)
where = append(where, fmt.Sprintf(`(
%s > $%d
OR (%s = $%d AND LOWER(%s) %s $%d)
OR (%s = $%d AND LOWER(%s) = $%d AND rp.id %s $%d)
)`, blankExpr, b, blankExpr, b, nameExpr, dirAfter, k, blankExpr, b, nameExpr, k, dirAfter, i))
return args, where, nil
case "createdAt":
ts, err := time.Parse(time.RFC3339Nano, cur.K)
if err != nil {
ts, err = time.Parse(time.RFC3339, cur.K)
}
if err != nil {
return args, where, fmt.Errorf("invalid cursor timestamp")
}
args = append(args, ts, id)
a, b := len(args)-1, len(args)
where = append(where, fmt.Sprintf("(rp.created_at, rp.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
return args, where, nil
default: // updatedAt
ts, err := time.Parse(time.RFC3339Nano, cur.K)
if err != nil {
ts, err = time.Parse(time.RFC3339, cur.K)
}
if err != nil {
return args, where, fmt.Errorf("invalid cursor timestamp")
}
args = append(args, ts, id)
a, b := len(args)-1, len(args)
where = append(where, fmt.Sprintf("(rp.updated_at, rp.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
return args, where, nil
}
}
// appendProcessedKeyset adds a keyset predicate for processed_products (alias p).
func appendProcessedKeyset(f ListFilter, cur productCursor, args []any, where []string) ([]any, []string, error) {
id, err := uuid.Parse(cur.ID)
if err != nil {
return args, where, fmt.Errorf("invalid cursor id")
}
dirAfter := keysetOp(f.SortOrder)
switch f.SortBy {
case "name":
nameExpr := `COALESCE(NULLIF(p.processed_name, ''), NULLIF(p.name, ''), '')`
blankExpr := fmt.Sprintf(`(CASE WHEN %s = '' THEN 1 ELSE 0 END)`, nameExpr)
args = append(args, boolToInt(cur.Blank), cur.K, id)
b, k, i := len(args)-2, len(args)-1, len(args)
where = append(where, fmt.Sprintf(`(
%s > $%d
OR (%s = $%d AND LOWER(%s) %s $%d)
OR (%s = $%d AND LOWER(%s) = $%d AND p.id %s $%d)
)`, blankExpr, b, blankExpr, b, nameExpr, dirAfter, k, blankExpr, b, nameExpr, k, dirAfter, i))
return args, where, nil
case "createdAt":
ts, err := time.Parse(time.RFC3339Nano, cur.K)
if err != nil {
ts, err = time.Parse(time.RFC3339, cur.K)
}
if err != nil {
return args, where, fmt.Errorf("invalid cursor timestamp")
}
args = append(args, ts, id)
a, b := len(args)-1, len(args)
where = append(where, fmt.Sprintf("(p.created_at, p.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
return args, where, nil
default: // updatedAt
ts, err := time.Parse(time.RFC3339Nano, cur.K)
if err != nil {
ts, err = time.Parse(time.RFC3339, cur.K)
}
if err != nil {
return args, where, fmt.Errorf("invalid cursor timestamp")
}
args = append(args, ts, id)
a, b := len(args)-1, len(args)
where = append(where, fmt.Sprintf("(p.updated_at, p.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
return args, where, nil
}
}
func keysetOp(sortOrder string) string {
if sortOrder == "asc" {
return ">"
}
return "<"
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
func productSortName(item map[string]any) string {
for _, key := range []string{"processed_name", "name"} {
if s := strings.TrimSpace(stringifyID(item[key])); s != "" {
return s
}
}
return ""
}
func stringifyID(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(t)
case uuid.UUID:
return t.String()
case [16]byte:
return uuid.UUID(t).String()
default:
return strings.TrimSpace(fmt.Sprint(t))
}
}
func asTime(v any) (time.Time, bool) {
switch t := v.(type) {
case time.Time:
return t, true
case *time.Time:
if t == nil {
return time.Time{}, false
}
return *t, true
case string:
if ts, err := time.Parse(time.RFC3339Nano, t); err == nil {
return ts, true
}
if ts, err := time.Parse(time.RFC3339, t); err == nil {
return ts, true
}
}
return time.Time{}, false
}
// resolveRawCursor decodes cursor or loads sort keys for after_id on raw_products.
// missing=true means after_id was not found for this company (caller should return an empty page).
func (s *Service) resolveRawCursor(ctx context.Context, companyID uuid.UUID, f ListFilter) (cur productCursor, use bool, missing bool, err error) {
if c := strings.TrimSpace(f.Cursor); c != "" {
cur, err = DecodeProductCursor(c)
if err != nil {
return productCursor{}, false, false, ClientMsg("invalid cursor")
}
if !cur.matchesFilter(f) {
return productCursor{}, false, false, ClientMsg("cursor sort mismatch")
}
return cur, true, false, nil
}
after := strings.TrimSpace(f.AfterID)
if after == "" {
return productCursor{}, false, false, nil
}
id, parseErr := uuid.Parse(after)
if parseErr != nil {
return productCursor{}, false, false, ClientMsg("invalid after_id")
}
var name string
var createdAt, updatedAt time.Time
scanErr := s.Pool.QueryRow(ctx, `
SELECT COALESCE(NULLIF(mapped_data->>'name', ''), NULLIF(mapped_data->>'title', ''), ''),
created_at, updated_at
FROM raw_products
WHERE id = $1 AND company_id = $2`, id, companyID).Scan(&name, &createdAt, &updatedAt)
if scanErr != nil {
if errors.Is(scanErr, pgx.ErrNoRows) {
return productCursor{}, true, true, nil
}
return productCursor{}, false, false, scanErr
}
cur = productCursorFromRow(f, id.String(), name, createdAt, updatedAt)
return cur, true, false, nil
}
// resolveProcessedCursor decodes cursor or loads sort keys for after_id on processed_products.
func (s *Service) resolveProcessedCursor(ctx context.Context, companyID uuid.UUID, f ListFilter) (cur productCursor, use bool, missing bool, err error) {
if c := strings.TrimSpace(f.Cursor); c != "" {
cur, err = DecodeProductCursor(c)
if err != nil {
return productCursor{}, false, false, ClientMsg("invalid cursor")
}
if !cur.matchesFilter(f) {
return productCursor{}, false, false, ClientMsg("cursor sort mismatch")
}
return cur, true, false, nil
}
after := strings.TrimSpace(f.AfterID)
if after == "" {
return productCursor{}, false, false, nil
}
id, parseErr := uuid.Parse(after)
if parseErr != nil {
return productCursor{}, false, false, ClientMsg("invalid after_id")
}
var name, processedName string
var createdAt, updatedAt time.Time
scanErr := s.Pool.QueryRow(ctx, `
SELECT COALESCE(name, ''), COALESCE(processed_name, ''), created_at, updated_at
FROM processed_products
WHERE id = $1 AND company_id = $2`, id, companyID).Scan(&name, &processedName, &createdAt, &updatedAt)
if scanErr != nil {
if errors.Is(scanErr, pgx.ErrNoRows) {
return productCursor{}, true, true, nil
}
return productCursor{}, false, false, scanErr
}
display := strings.TrimSpace(processedName)
if display == "" {
display = strings.TrimSpace(name)
}
cur = productCursorFromRow(f, id.String(), display, createdAt, updatedAt)
return cur, true, false, nil
}
func productCursorFromRow(f ListFilter, id, name string, createdAt, updatedAt time.Time) productCursor {
cur := productCursor{
V: productCursorVersion,
ID: id,
SB: f.SortBy,
SO: f.SortOrder,
}
switch f.SortBy {
case "name":
cur.Blank = name == ""
cur.K = strings.ToLower(name)
case "createdAt":
cur.K = createdAt.UTC().Format(time.RFC3339Nano)
default:
cur.K = updatedAt.UTC().Format(time.RFC3339Nano)
}
return cur
}