Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
371 lines
11 KiB
Go
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
|
|
}
|