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 }