feat: paginate and search brand questions across selects
Make GET /api/tenant/brands/:id/questions return a paginated QuestionListResponse (items/total/page/page_size) with optional `q` full-text filter, validated page/page_size query params, and per-params cache keys. Add a usePaginatedBrandQuestions composable backing the imitation, template-wizard, and tracking question selects with search-as-you-type and infinite scroll, plus ensure-loaded-by-id so a deep-linked or pre-selected question is fetched even when off the first page. Brands view now drives its questions table via server pagination. Also redesign the Tracking hot-questions and cited-articles lists (mention-rate badges/bars, metric groups, refreshed styling) and fall back to citation-fact article_id/title when no high-confidence URL alias matches, so cited articles surface even without an alias row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -474,10 +474,44 @@ type QuestionResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keywordID *int64) ([]QuestionResponse, error) {
|
||||
type QuestionListParams struct {
|
||||
KeywordID *int64
|
||||
Page int
|
||||
PageSize int
|
||||
Query string
|
||||
}
|
||||
|
||||
type QuestionListResponse struct {
|
||||
Items []QuestionResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
func normalizeQuestionListParams(params QuestionListParams) QuestionListParams {
|
||||
params.Query = strings.TrimSpace(params.Query)
|
||||
if params.Page < 1 {
|
||||
params.Page = 1
|
||||
}
|
||||
if params.PageSize < 1 {
|
||||
params.PageSize = 10
|
||||
}
|
||||
if params.PageSize > 100 {
|
||||
params.PageSize = 100
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func (params QuestionListParams) offset() int {
|
||||
params = normalizeQuestionListParams(params)
|
||||
return (params.Page - 1) * params.PageSize
|
||||
}
|
||||
|
||||
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, params QuestionListParams) (*QuestionListResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, keywordID), defaultCacheTTL(), func(loadCtx context.Context) ([]QuestionResponse, error) {
|
||||
return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, keywordID)
|
||||
params = normalizeQuestionListParams(params)
|
||||
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, params), defaultCacheTTL(), func(loadCtx context.Context) (*QuestionListResponse, error) {
|
||||
return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, params)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -857,18 +891,44 @@ func (s *BrandService) loadBrandKeywords(ctx context.Context, tenantID, brandID
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, keywordID *int64) ([]QuestionResponse, error) {
|
||||
func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, params QuestionListParams) (*QuestionListResponse, error) {
|
||||
params = normalizeQuestionListParams(params)
|
||||
|
||||
countQuery := `
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM brand_questions q
|
||||
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
|
||||
countArgs := []interface{}{brandID, tenantID}
|
||||
if params.KeywordID != nil {
|
||||
countQuery += ` AND q.keyword_id = $3`
|
||||
countArgs = append(countArgs, *params.KeywordID)
|
||||
}
|
||||
if params.Query != "" {
|
||||
countArgs = append(countArgs, "%"+params.Query+"%")
|
||||
countQuery += fmt.Sprintf(` AND q.question_text ILIKE $%d`, len(countArgs))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to count questions")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT q.id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at
|
||||
FROM brand_questions q
|
||||
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
|
||||
args := []interface{}{brandID, tenantID}
|
||||
|
||||
if keywordID != nil {
|
||||
if params.KeywordID != nil {
|
||||
query += ` AND q.keyword_id = $3`
|
||||
args = append(args, *keywordID)
|
||||
args = append(args, *params.KeywordID)
|
||||
}
|
||||
query += ` ORDER BY q.created_at DESC`
|
||||
if params.Query != "" {
|
||||
args = append(args, "%"+params.Query+"%")
|
||||
query += fmt.Sprintf(` AND q.question_text ILIKE $%d`, len(args))
|
||||
}
|
||||
query += fmt.Sprintf(` ORDER BY q.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||
args = append(args, params.PageSize, params.offset())
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -886,7 +946,18 @@ func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID
|
||||
item.CreatedAt = fmt.Sprintf("%v", createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
if items == nil {
|
||||
items = []QuestionResponse{}
|
||||
}
|
||||
return &QuestionListResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: params.Page,
|
||||
PageSize: params.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brandID int64) ([]CompetitorResponse, error) {
|
||||
|
||||
Reference in New Issue
Block a user