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.
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user