feat(admin-brand): add question expansion service and related functionality
- Implemented QuestionExpansionService for generating and materializing questions based on combinations and AI distillation. - Added question metadata classification logic to infer intent and layer of questions. - Created API handlers for question expansion operations including combination preview, AI distillation, metadata classification, and materialization. - Introduced database migrations to support new question-related fields and constraints in brand_questions and brand_keywords tables. - Added caching mechanism for AI distillation results to improve performance. - Defined JSON schemas for question distillation responses to ensure data integrity.
This commit is contained in:
@@ -68,15 +68,16 @@ type BrandRequest struct {
|
||||
}
|
||||
|
||||
type BrandResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Website *string `json:"website"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
KeywordCount int `json:"keyword_count"`
|
||||
QuestionCount int `json:"question_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Website *string `json:"website"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
KeywordCount int `json:"keyword_count"`
|
||||
QuestionCount int `json:"question_count"`
|
||||
CompetitorCount int `json:"competitor_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type BrandLibrarySummaryResponse struct {
|
||||
@@ -88,7 +89,11 @@ type BrandLibrarySummaryResponse struct {
|
||||
MaxKeywords int `json:"max_keywords"`
|
||||
UsedKeywords int `json:"used_keywords"`
|
||||
RemainingKeywords int `json:"remaining_keywords"`
|
||||
MaxQuestions int `json:"max_questions"`
|
||||
UsedQuestions int `json:"used_questions"`
|
||||
RemainingQuestions int `json:"remaining_questions"`
|
||||
MaxQuestionsPerKeyword int `json:"max_questions_per_keyword"`
|
||||
MaxQuestionsPerBrand int `json:"max_questions_per_brand"`
|
||||
}
|
||||
|
||||
func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
|
||||
@@ -117,9 +122,17 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to begin brand transaction")
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
|
||||
var id int64
|
||||
var ca interface{}
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
err = tx.QueryRow(ctx, `
|
||||
WITH usage AS (
|
||||
SELECT COUNT(*)::INT AS used_brands
|
||||
FROM brands
|
||||
@@ -141,6 +154,13 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to create brand")
|
||||
}
|
||||
|
||||
if _, err := ensureDefaultQuestionBucketTx(ctx, tx, actor.TenantID, id, req.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to commit brand")
|
||||
}
|
||||
|
||||
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name})
|
||||
result := "success"
|
||||
resourceType := "brand"
|
||||
@@ -159,14 +179,15 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
|
||||
|
||||
invalidateBrandCaches(ctx, s.cache, actor.TenantID, id)
|
||||
return &BrandResponse{
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
Website: req.Website,
|
||||
Description: req.Description,
|
||||
Status: "active",
|
||||
KeywordCount: 0,
|
||||
QuestionCount: 0,
|
||||
CreatedAt: fmt.Sprintf("%v", ca),
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
Website: req.Website,
|
||||
Description: req.Description,
|
||||
Status: "active",
|
||||
KeywordCount: 0,
|
||||
QuestionCount: 0,
|
||||
CompetitorCount: 0,
|
||||
CreatedAt: fmt.Sprintf("%v", ca),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -470,29 +491,47 @@ func (s *BrandService) CreateQuestion(ctx context.Context, brandID int64, req Qu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to begin question transaction")
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, tenantQuestionQuotaLockKey(actor.TenantID), "questions"); err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to lock question quota")
|
||||
}
|
||||
|
||||
var questionID int64
|
||||
var ca interface{}
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
err = tx.QueryRow(ctx, `
|
||||
WITH usage AS (
|
||||
SELECT COUNT(*)::INT AS used_questions
|
||||
FROM brand_questions
|
||||
WHERE tenant_id = $1 AND keyword_id = $3 AND deleted_at IS NULL
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
)
|
||||
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status)
|
||||
SELECT $1, $2, $3, $4, 'active'
|
||||
FROM usage
|
||||
WHERE usage.used_questions < $5
|
||||
RETURNING id, created_at
|
||||
`, actor.TenantID, brandID, req.KeywordID, req.QuestionText, summary.MaxQuestionsPerKeyword).Scan(&questionID, &ca)
|
||||
`, actor.TenantID, brandID, req.KeywordID, req.QuestionText, summary.MaxQuestions).Scan(&questionID, &ca)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40906, "question_limit_reached", fmt.Sprintf("each keyword allows up to %d questions", summary.MaxQuestionsPerKeyword))
|
||||
return nil, response.ErrConflict(40906, "question_limit_reached", fmt.Sprintf("current account allows up to %d questions", summary.MaxQuestions))
|
||||
}
|
||||
if isUniqueQuestionConstraintError(err) {
|
||||
return nil, response.ErrConflict(40907, "question_exists", "question already exists for this brand")
|
||||
}
|
||||
if isForeignKeyConstraintError(err) {
|
||||
return nil, response.ErrNotFound(40421, "keyword_not_found", "keyword not found")
|
||||
}
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to create question")
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50010, "create_failed", "failed to commit question")
|
||||
}
|
||||
|
||||
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
||||
return &QuestionResponse{
|
||||
@@ -516,11 +555,26 @@ func (s *BrandService) UpdateQuestion(ctx context.Context, brandID, questionID i
|
||||
return response.ErrBadRequest(40001, "invalid_params", "question_text is required")
|
||||
}
|
||||
|
||||
classified, err := s.classifyQuestionForUpdate(ctx, actor.TenantID, brandID, req.QuestionText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE brand_questions SET question_text = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND brand_id = $3 AND tenant_id = $4 AND deleted_at IS NULL
|
||||
`, req.QuestionText, questionID, brandID, actor.TenantID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
UPDATE brand_questions
|
||||
SET question_text = $1,
|
||||
layer = $2,
|
||||
intent = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4 AND brand_id = $5 AND tenant_id = $6 AND deleted_at IS NULL
|
||||
`, req.QuestionText, classified.Layer, classified.Intent, questionID, brandID, actor.TenantID)
|
||||
if err != nil {
|
||||
if isUniqueQuestionConstraintError(err) {
|
||||
return response.ErrConflict(40907, "question_exists", "question already exists for this brand")
|
||||
}
|
||||
return response.ErrInternal(50010, "update_failed", "failed to update question")
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40422, "question_not_found", "question not found")
|
||||
}
|
||||
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
||||
@@ -641,24 +695,35 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
|
||||
b.website,
|
||||
b.description,
|
||||
b.status,
|
||||
COALESCE(stats.keyword_count, 0) AS keyword_count,
|
||||
COALESCE(stats.question_count, 0) AS question_count,
|
||||
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
|
||||
COALESCE(question_stats.question_count, 0) AS question_count,
|
||||
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
|
||||
b.created_at,
|
||||
b.updated_at
|
||||
FROM brands b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(DISTINCT k.id)::INT AS keyword_count,
|
||||
COUNT(q.id)::INT AS question_count
|
||||
COUNT(DISTINCT k.id)::INT AS keyword_count
|
||||
FROM brand_keywords k
|
||||
LEFT JOIN brand_questions q
|
||||
ON q.keyword_id = k.id
|
||||
AND q.tenant_id = b.tenant_id
|
||||
AND q.deleted_at IS NULL
|
||||
WHERE k.brand_id = b.id
|
||||
AND k.tenant_id = b.tenant_id
|
||||
AND COALESCE(k.source, 'manual') <> 'auto'
|
||||
AND k.deleted_at IS NULL
|
||||
) stats ON true
|
||||
) keyword_stats ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::INT AS question_count
|
||||
FROM brand_questions q
|
||||
WHERE q.brand_id = b.id
|
||||
AND q.tenant_id = b.tenant_id
|
||||
AND q.deleted_at IS NULL
|
||||
) question_stats ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::INT AS competitor_count
|
||||
FROM competitors c
|
||||
WHERE c.brand_id = b.id
|
||||
AND c.tenant_id = b.tenant_id
|
||||
AND c.deleted_at IS NULL
|
||||
) competitor_stats ON true
|
||||
WHERE b.tenant_id = $1 AND b.deleted_at IS NULL
|
||||
ORDER BY b.created_at DESC
|
||||
`, tenantID)
|
||||
@@ -672,7 +737,7 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
|
||||
var item BrandResponse
|
||||
var createdAt interface{}
|
||||
var updatedAt interface{}
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &createdAt, &updatedAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
item.CreatedAt = fmt.Sprintf("%v", createdAt)
|
||||
@@ -693,26 +758,37 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in
|
||||
b.website,
|
||||
b.description,
|
||||
b.status,
|
||||
COALESCE(stats.keyword_count, 0) AS keyword_count,
|
||||
COALESCE(stats.question_count, 0) AS question_count,
|
||||
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
|
||||
COALESCE(question_stats.question_count, 0) AS question_count,
|
||||
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
|
||||
b.created_at,
|
||||
b.updated_at
|
||||
FROM brands b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(DISTINCT k.id)::INT AS keyword_count,
|
||||
COUNT(q.id)::INT AS question_count
|
||||
COUNT(DISTINCT k.id)::INT AS keyword_count
|
||||
FROM brand_keywords k
|
||||
LEFT JOIN brand_questions q
|
||||
ON q.keyword_id = k.id
|
||||
AND q.tenant_id = b.tenant_id
|
||||
AND q.deleted_at IS NULL
|
||||
WHERE k.brand_id = b.id
|
||||
AND k.tenant_id = b.tenant_id
|
||||
AND COALESCE(k.source, 'manual') <> 'auto'
|
||||
AND k.deleted_at IS NULL
|
||||
) stats ON true
|
||||
) keyword_stats ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::INT AS question_count
|
||||
FROM brand_questions q
|
||||
WHERE q.brand_id = b.id
|
||||
AND q.tenant_id = b.tenant_id
|
||||
AND q.deleted_at IS NULL
|
||||
) question_stats ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::INT AS competitor_count
|
||||
FROM competitors c
|
||||
WHERE c.brand_id = b.id
|
||||
AND c.tenant_id = b.tenant_id
|
||||
AND c.deleted_at IS NULL
|
||||
) competitor_stats ON true
|
||||
WHERE b.id = $1 AND b.tenant_id = $2 AND b.deleted_at IS NULL
|
||||
`, brandID, tenantID).Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &createdAt, &updatedAt)
|
||||
`, brandID, tenantID).Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
@@ -726,8 +802,12 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in
|
||||
|
||||
func (s *BrandService) loadBrandKeywords(ctx context.Context, tenantID, brandID int64) ([]KeywordResponse, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, brand_id, name, status, created_at FROM brand_keywords
|
||||
WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC
|
||||
SELECT id, brand_id, name, status, created_at FROM brand_keywords
|
||||
WHERE brand_id = $1
|
||||
AND tenant_id = $2
|
||||
AND deleted_at IS NULL
|
||||
AND COALESCE(source, 'manual') <> 'auto'
|
||||
ORDER BY created_at DESC
|
||||
`, brandID, tenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to list keywords")
|
||||
@@ -808,14 +888,14 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand
|
||||
}
|
||||
|
||||
type brandLibraryPlan struct {
|
||||
PlanCode string
|
||||
PlanName string
|
||||
MaxBrands int
|
||||
PlanCode string
|
||||
PlanName string
|
||||
}
|
||||
|
||||
type brandLibraryUsage struct {
|
||||
BrandCount int
|
||||
KeywordCount int
|
||||
BrandCount int
|
||||
KeywordCount int
|
||||
QuestionCount int
|
||||
}
|
||||
|
||||
func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int64) (*BrandLibrarySummaryResponse, error) {
|
||||
@@ -831,10 +911,8 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
|
||||
|
||||
limits := s.currentLimits()
|
||||
maxBrands := limits.BrandLimitForPlan(plan.PlanCode)
|
||||
if plan.MaxBrands > 0 {
|
||||
maxBrands = plan.MaxBrands
|
||||
}
|
||||
maxKeywords := limits.MaxKeywords
|
||||
maxQuestions := limits.QuestionLimitForPlan(plan.PlanCode)
|
||||
|
||||
return &BrandLibrarySummaryResponse{
|
||||
PlanCode: plan.PlanCode,
|
||||
@@ -845,21 +923,22 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
|
||||
MaxKeywords: maxKeywords,
|
||||
UsedKeywords: usage.KeywordCount,
|
||||
RemainingKeywords: maxInt(maxKeywords-usage.KeywordCount, 0),
|
||||
MaxQuestions: maxQuestions,
|
||||
UsedQuestions: usage.QuestionCount,
|
||||
RemainingQuestions: maxInt(maxQuestions-usage.QuestionCount, 0),
|
||||
MaxQuestionsPerKeyword: limits.MaxQuestionsPerKeyword,
|
||||
MaxQuestionsPerBrand: maxQuestions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) {
|
||||
limits := s.currentLimits()
|
||||
plan := &brandLibraryPlan{
|
||||
PlanCode: "free",
|
||||
PlanName: "",
|
||||
MaxBrands: limits.BrandLimitForPlan("free"),
|
||||
PlanCode: "free",
|
||||
PlanName: "",
|
||||
}
|
||||
|
||||
var quotaPolicyJSON []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT p.plan_code, p.name, p.quota_policy_json
|
||||
SELECT p.plan_code, p.name
|
||||
FROM tenant_plan_subscriptions s
|
||||
JOIN plans p ON p.id = s.plan_id
|
||||
WHERE s.tenant_id = $1
|
||||
@@ -869,22 +948,13 @@ func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64)
|
||||
AND s.end_at > $2
|
||||
ORDER BY s.start_at DESC
|
||||
LIMIT 1
|
||||
`, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName, "aPolicyJSON)
|
||||
`, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return plan, nil
|
||||
}
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to load active plan")
|
||||
}
|
||||
var quotaPolicy struct {
|
||||
BrandLimit int `json:"brand_limit"`
|
||||
}
|
||||
if len(quotaPolicyJSON) > 0 {
|
||||
_ = json.Unmarshal(quotaPolicyJSON, "aPolicy)
|
||||
}
|
||||
if quotaPolicy.BrandLimit > 0 {
|
||||
plan.MaxBrands = quotaPolicy.BrandLimit
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
@@ -893,8 +963,9 @@ func (s *BrandService) loadBrandLibraryUsage(ctx context.Context, tenantID int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*)::INT FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL) AS brand_count,
|
||||
(SELECT COUNT(*)::INT FROM brand_keywords WHERE tenant_id = $1 AND deleted_at IS NULL) AS keyword_count
|
||||
`, tenantID).Scan(&usage.BrandCount, &usage.KeywordCount)
|
||||
(SELECT COUNT(*)::INT FROM brand_keywords WHERE tenant_id = $1 AND deleted_at IS NULL AND COALESCE(source, 'manual') <> 'auto') AS keyword_count,
|
||||
(SELECT COUNT(*)::INT FROM brand_questions WHERE tenant_id = $1 AND deleted_at IS NULL) AS question_count
|
||||
`, tenantID).Scan(&usage.BrandCount, &usage.KeywordCount, &usage.QuestionCount)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to load brand library usage")
|
||||
}
|
||||
@@ -919,7 +990,7 @@ func (s *BrandService) brandExists(ctx context.Context, tenantID, brandID int64)
|
||||
func (s *BrandService) keywordExistsForBrand(ctx context.Context, tenantID, brandID, keywordID int64) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM brand_keywords
|
||||
WHERE id = $1
|
||||
@@ -934,6 +1005,82 @@ func (s *BrandService) keywordExistsForBrand(ctx context.Context, tenantID, bran
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *BrandService) classifyQuestionForUpdate(ctx context.Context, tenantID, brandID int64, text string) (ClassifiedQuestion, error) {
|
||||
var brandName string
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT name
|
||||
FROM brands
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, brandID, tenantID).Scan(&brandName); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ClassifiedQuestion{}, response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
||||
}
|
||||
return ClassifiedQuestion{}, response.ErrInternal(50010, "query_failed", "failed to load brand")
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT name
|
||||
FROM competitors
|
||||
WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, brandID, tenantID)
|
||||
if err != nil {
|
||||
return ClassifiedQuestion{}, response.ErrInternal(50010, "query_failed", "failed to load competitors")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
competitors := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return ClassifiedQuestion{}, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
competitors = append(competitors, name)
|
||||
}
|
||||
return classifyQuestionText(text, brandName, competitors), nil
|
||||
}
|
||||
|
||||
func ensureDefaultQuestionBucketTx(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, brandName string) (int64, error) {
|
||||
var id int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT id
|
||||
FROM brand_keywords
|
||||
WHERE tenant_id = $1
|
||||
AND brand_id = $2
|
||||
AND source = 'auto'
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`, tenantID, brandID).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, response.ErrInternal(50010, "default_bucket_failed", "failed to load default question bucket")
|
||||
}
|
||||
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO brand_keywords (
|
||||
tenant_id, brand_id, name, status,
|
||||
layer, seed_word, source
|
||||
) VALUES (
|
||||
$1, $2, '__default_questions__', 'active',
|
||||
'L1', $3, 'auto'
|
||||
)
|
||||
ON CONFLICT (brand_id, name) WHERE deleted_at IS NULL
|
||||
DO UPDATE
|
||||
SET source = 'auto',
|
||||
layer = 'L1',
|
||||
seed_word = COALESCE(brand_keywords.seed_word, EXCLUDED.seed_word),
|
||||
updated_at = brand_keywords.updated_at
|
||||
RETURNING id
|
||||
`, tenantID, brandID, brandName).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, response.ErrInternal(50010, "default_bucket_failed", "failed to create default question bucket")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func isUniqueConstraintError(err error, constraint string) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == constraint
|
||||
|
||||
Reference in New Issue
Block a user