6e0519a232
- Implemented a new BrandAssetCleanupWorker to handle the cleanup of brand-related assets after a brand is deleted. - Added SQL queries for cleaning up articles, keywords, questions, competitors, and monitoring data associated with a brand. - Introduced a new API endpoint to delete publish records. - Updated the router to include the new delete publish record endpoint. - Added tests for the BrandAssetCleanupWorker to ensure proper functionality. - Created migration scripts to support soft deletion of publish records and to add the brand asset cleanup scheduler job.
1234 lines
41 KiB
Go
1234 lines
41 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/sync/singleflight"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
|
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
|
|
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
)
|
|
|
|
type BrandService struct {
|
|
pool *pgxpool.Pool
|
|
monitoringPool *pgxpool.Pool
|
|
limitsMu sync.RWMutex
|
|
limits sharedconfig.BrandLibraryConfig
|
|
auditLogs *auditlog.AsyncWriter
|
|
cache sharedcache.Cache
|
|
cacheGroup singleflight.Group
|
|
}
|
|
|
|
func NewBrandService(pool, monitoringPool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, limits sharedconfig.BrandLibraryConfig) *BrandService {
|
|
return &BrandService{pool: pool, monitoringPool: monitoringPool, limits: limits, auditLogs: auditLogs}
|
|
}
|
|
|
|
func (s *BrandService) WithCache(c sharedcache.Cache) *BrandService {
|
|
s.cache = c
|
|
return s
|
|
}
|
|
|
|
func (s *BrandService) UpdateConfig(limits sharedconfig.BrandLibraryConfig) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.limitsMu.Lock()
|
|
s.limits = limits
|
|
s.limitsMu.Unlock()
|
|
}
|
|
|
|
func (s *BrandService) currentLimits() sharedconfig.BrandLibraryConfig {
|
|
if s == nil {
|
|
return sharedconfig.BrandLibraryConfig{}
|
|
}
|
|
s.limitsMu.RLock()
|
|
defer s.limitsMu.RUnlock()
|
|
return s.limits
|
|
}
|
|
|
|
// --- Brand CRUD ---
|
|
|
|
type BrandRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Website *string `json:"website"`
|
|
Description *string `json:"description"`
|
|
}
|
|
|
|
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"`
|
|
CompetitorCount int `json:"competitor_count"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type BrandLibrarySummaryResponse struct {
|
|
PlanCode string `json:"plan_code"`
|
|
PlanName string `json:"plan_name"`
|
|
MaxBrands int `json:"max_brands"`
|
|
UsedBrands int `json:"used_brands"`
|
|
RemainingBrands int `json:"remaining_brands"`
|
|
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 formatBrandTime(value interface{}) string {
|
|
switch typed := value.(type) {
|
|
case time.Time:
|
|
return typed.UTC().Format(time.RFC3339Nano)
|
|
case *time.Time:
|
|
if typed == nil {
|
|
return ""
|
|
}
|
|
return typed.UTC().Format(time.RFC3339Nano)
|
|
default:
|
|
return fmt.Sprintf("%v", value)
|
|
}
|
|
}
|
|
|
|
func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
brands, err := sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandListCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]BrandResponse, error) {
|
|
return s.loadBrands(loadCtx, actor.TenantID)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if brands == nil {
|
|
return []BrandResponse{}, nil
|
|
}
|
|
return brands, nil
|
|
}
|
|
|
|
func (s *BrandService) Summary(ctx context.Context) (*BrandLibrarySummaryResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandLibrarySummaryCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) (*BrandLibrarySummaryResponse, error) {
|
|
return s.loadBrandLibrarySummary(loadCtx, actor.TenantID)
|
|
})
|
|
}
|
|
|
|
func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
normalizeBrandRequest(&req)
|
|
if req.Name == "" {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "name is required")
|
|
}
|
|
if req.Description == nil {
|
|
return nil, response.ErrBadRequest(40002, "brand_description_required", "description is required")
|
|
}
|
|
|
|
summary, err := s.loadBrandLibrarySummary(ctx, actor.TenantID)
|
|
if err != nil {
|
|
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 = tx.QueryRow(ctx, `
|
|
WITH usage AS (
|
|
SELECT COUNT(*)::INT AS used_brands
|
|
FROM brands
|
|
WHERE tenant_id = $1 AND deleted_at IS NULL
|
|
)
|
|
INSERT INTO brands (tenant_id, name, website, description, status)
|
|
SELECT $1, $2, $3, $4, 'active'
|
|
FROM usage
|
|
WHERE usage.used_brands < $5
|
|
RETURNING id, created_at
|
|
`, actor.TenantID, req.Name, req.Website, req.Description, summary.MaxBrands).Scan(&id, &ca)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrConflict(40904, "brand_limit_reached", fmt.Sprintf("current plan allows up to %d brand companies", summary.MaxBrands))
|
|
}
|
|
if isUniqueConstraintError(err, "uk_brand_tenant_name_active") {
|
|
return nil, response.ErrConflict(40901, "brand_exists", "brand with this name already exists")
|
|
}
|
|
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"
|
|
requestID := middleware.RequestIDFromContext(ctx)
|
|
s.auditLogs.Log(auditlog.Entry{
|
|
OperatorID: actor.UserID,
|
|
TenantID: &actor.TenantID,
|
|
Module: "brand",
|
|
Action: "create",
|
|
ResourceType: &resourceType,
|
|
ResourceID: &id,
|
|
RequestID: nilIfEmptyString(requestID),
|
|
AfterJSON: afterJSON,
|
|
Result: &result,
|
|
})
|
|
|
|
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,
|
|
CompetitorCount: 0,
|
|
CreatedAt: formatBrandTime(ca),
|
|
}, nil
|
|
}
|
|
|
|
func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, brandDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*BrandResponse, bool, error) {
|
|
return s.loadBrandDetail(loadCtx, actor.TenantID, id)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !found || record == nil {
|
|
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
func (s *BrandService) Update(ctx context.Context, id int64, req BrandRequest) error {
|
|
actor := auth.MustActor(ctx)
|
|
normalizeBrandRequest(&req)
|
|
if req.Name == "" {
|
|
return response.ErrBadRequest(40001, "invalid_params", "name is required")
|
|
}
|
|
if req.Description == nil {
|
|
return response.ErrBadRequest(40002, "brand_description_required", "description is required")
|
|
}
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE brands SET name = $1, website = $2, description = $3, updated_at = NOW()
|
|
WHERE id = $4 AND tenant_id = $5 AND deleted_at IS NULL
|
|
`, req.Name, req.Website, req.Description, id, actor.TenantID)
|
|
if err != nil {
|
|
if isUniqueConstraintError(err, "uk_brand_tenant_name_active") {
|
|
return response.ErrConflict(40901, "brand_exists", "brand with this name already exists")
|
|
}
|
|
return response.ErrInternal(50010, "update_failed", "failed to update brand")
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
|
}
|
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, id)
|
|
return nil
|
|
}
|
|
|
|
func normalizeBrandRequest(req *BrandRequest) {
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
req.Website = normalizeOptionalString(req.Website)
|
|
req.Description = normalizeOptionalString(req.Description)
|
|
}
|
|
|
|
func normalizeOptionalString(value *string) *string {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return nilIfEmptyString(*value)
|
|
}
|
|
|
|
func (s *BrandService) Delete(ctx context.Context, id int64) error {
|
|
actor := auth.MustActor(ctx)
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = tx.Rollback(ctx)
|
|
}()
|
|
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE brands
|
|
SET status = 'deleting',
|
|
deleted_at = COALESCE(deleted_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
`, id, actor.TenantID)
|
|
if err != nil {
|
|
return response.ErrInternal(50010, "delete_failed", "failed to mark brand for deletion")
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
var alreadyDeleted bool
|
|
if lookupErr := tx.QueryRow(ctx, `
|
|
SELECT deleted_at IS NOT NULL
|
|
FROM brands
|
|
WHERE id = $1 AND tenant_id = $2
|
|
`, id, actor.TenantID).Scan(&alreadyDeleted); lookupErr != nil {
|
|
if errors.Is(lookupErr, pgx.ErrNoRows) {
|
|
return response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
|
}
|
|
return response.ErrInternal(50010, "delete_failed", "failed to inspect brand deletion state")
|
|
}
|
|
if !alreadyDeleted {
|
|
return response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
|
}
|
|
return nil
|
|
}
|
|
_, _ = tx.Exec(ctx, `UPDATE brand_keywords SET deleted_at = NOW(), updated_at = NOW() WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, id, actor.TenantID)
|
|
_, _ = tx.Exec(ctx, `UPDATE brand_questions SET deleted_at = NOW(), updated_at = NOW() WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, id, actor.TenantID)
|
|
_, _ = tx.Exec(ctx, `UPDATE competitors SET deleted_at = NOW(), updated_at = NOW() WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, id, actor.TenantID)
|
|
|
|
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "status": "deleting", "cleanup": "background"})
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
result := "success"
|
|
resourceType := "brand"
|
|
requestID := middleware.RequestIDFromContext(ctx)
|
|
s.auditLogs.Log(auditlog.Entry{
|
|
OperatorID: actor.UserID,
|
|
TenantID: &actor.TenantID,
|
|
Module: "brand",
|
|
Action: "delete",
|
|
ResourceType: &resourceType,
|
|
ResourceID: &id,
|
|
RequestID: nilIfEmptyString(requestID),
|
|
AfterJSON: afterJSON,
|
|
Result: &result,
|
|
})
|
|
|
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, id)
|
|
return nil
|
|
}
|
|
|
|
// --- Keyword CRUD ---
|
|
|
|
type KeywordRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
}
|
|
|
|
type KeywordResponse struct {
|
|
ID int64 `json:"id"`
|
|
BrandID int64 `json:"brand_id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func (s *BrandService) ListKeywords(ctx context.Context, brandID int64) ([]KeywordResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandKeywordsCacheKey(actor.TenantID, brandID), defaultCacheTTL(), func(loadCtx context.Context) ([]KeywordResponse, error) {
|
|
return s.loadBrandKeywords(loadCtx, actor.TenantID, brandID)
|
|
})
|
|
}
|
|
|
|
func (s *BrandService) CreateKeyword(ctx context.Context, brandID int64, req KeywordRequest) (*KeywordResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Name == "" {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "name is required")
|
|
}
|
|
exists, err := s.brandExists(ctx, actor.TenantID, brandID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
|
}
|
|
|
|
summary, err := s.loadBrandLibrarySummary(ctx, actor.TenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var id int64
|
|
var ca interface{}
|
|
err = s.pool.QueryRow(ctx, `
|
|
WITH usage AS (
|
|
SELECT COUNT(*)::INT AS used_keywords
|
|
FROM brand_keywords
|
|
WHERE tenant_id = $1 AND deleted_at IS NULL
|
|
)
|
|
INSERT INTO brand_keywords (tenant_id, brand_id, name, status)
|
|
SELECT $1, $2, $3, 'active'
|
|
FROM usage
|
|
WHERE usage.used_keywords < $4
|
|
RETURNING id, created_at
|
|
`, actor.TenantID, brandID, req.Name, summary.MaxKeywords).Scan(&id, &ca)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrConflict(40905, "keyword_limit_reached", fmt.Sprintf("current plan allows up to %d keywords", summary.MaxKeywords))
|
|
}
|
|
if isUniqueConstraintError(err, "uk_brand_keyword_name_active") {
|
|
return nil, response.ErrConflict(40902, "keyword_exists", "keyword with this name already exists for this brand")
|
|
}
|
|
if isForeignKeyConstraintError(err) {
|
|
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
|
}
|
|
return nil, response.ErrInternal(50010, "create_failed", "failed to create keyword")
|
|
}
|
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
|
return &KeywordResponse{ID: id, BrandID: brandID, Name: req.Name, Status: "active", CreatedAt: formatBrandTime(ca)}, nil
|
|
}
|
|
|
|
func (s *BrandService) UpdateKeyword(ctx context.Context, brandID, keywordID int64, req KeywordRequest) error {
|
|
actor := auth.MustActor(ctx)
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Name == "" {
|
|
return response.ErrBadRequest(40001, "invalid_params", "name is required")
|
|
}
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE brand_keywords SET name = $1, updated_at = NOW()
|
|
WHERE id = $2 AND brand_id = $3 AND tenant_id = $4 AND deleted_at IS NULL
|
|
`, req.Name, keywordID, brandID, actor.TenantID)
|
|
if err != nil {
|
|
if isUniqueConstraintError(err, "uk_brand_keyword_name_active") {
|
|
return response.ErrConflict(40902, "keyword_exists", "keyword with this name already exists for this brand")
|
|
}
|
|
return response.ErrInternal(50010, "update_failed", "failed to update keyword")
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40421, "keyword_not_found", "keyword not found")
|
|
}
|
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
|
return nil
|
|
}
|
|
|
|
func (s *BrandService) DeleteKeyword(ctx context.Context, brandID, keywordID int64) error {
|
|
actor := auth.MustActor(ctx)
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = tx.Rollback(ctx)
|
|
}()
|
|
|
|
questionRows, err := tx.Query(ctx, `
|
|
SELECT id
|
|
FROM brand_questions
|
|
WHERE brand_id = $1
|
|
AND keyword_id = $2
|
|
AND tenant_id = $3
|
|
AND deleted_at IS NULL
|
|
`, brandID, keywordID, actor.TenantID)
|
|
if err != nil {
|
|
return response.ErrInternal(50010, "query_failed", "failed to load keyword questions")
|
|
}
|
|
questionIDs := make([]int64, 0)
|
|
for questionRows.Next() {
|
|
var questionID int64
|
|
if scanErr := questionRows.Scan(&questionID); scanErr != nil {
|
|
return response.ErrInternal(50010, "scan_failed", scanErr.Error())
|
|
}
|
|
questionIDs = append(questionIDs, questionID)
|
|
}
|
|
if err := questionRows.Err(); err != nil {
|
|
return response.ErrInternal(50010, "scan_failed", err.Error())
|
|
}
|
|
questionRows.Close()
|
|
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE brand_keywords SET deleted_at = NOW(), updated_at = NOW()
|
|
WHERE id = $1 AND brand_id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
|
`, keywordID, brandID, actor.TenantID)
|
|
if err != nil || tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40421, "keyword_not_found", "keyword not found")
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE brand_questions
|
|
SET deleted_at = NOW(), updated_at = NOW()
|
|
WHERE brand_id = $1
|
|
AND keyword_id = $2
|
|
AND tenant_id = $3
|
|
AND deleted_at IS NULL
|
|
`, brandID, keywordID, actor.TenantID); err != nil {
|
|
return response.ErrInternal(50010, "update_failed", "failed to soft delete questions under keyword")
|
|
}
|
|
|
|
if err := s.cleanupMonitoringAfterKeywordDelete(ctx, actor.TenantID, brandID, keywordID, questionIDs); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
|
return nil
|
|
}
|
|
|
|
// --- Question CRUD ---
|
|
|
|
type QuestionRequest struct {
|
|
KeywordID int64 `json:"keyword_id" binding:"required"`
|
|
QuestionText string `json:"question_text" binding:"required"`
|
|
}
|
|
|
|
type QuestionResponse struct {
|
|
ID int64 `json:"id"`
|
|
BrandID int64 `json:"brand_id"`
|
|
KeywordID int64 `json:"keyword_id"`
|
|
QuestionText string `json:"question_text"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
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)
|
|
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)
|
|
})
|
|
}
|
|
|
|
func (s *BrandService) CreateQuestion(ctx context.Context, brandID int64, req QuestionRequest) (*QuestionResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
if req.KeywordID <= 0 {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "keyword_id is required")
|
|
}
|
|
req.QuestionText = strings.TrimSpace(req.QuestionText)
|
|
if req.QuestionText == "" {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "question_text is required")
|
|
}
|
|
|
|
exists, err := s.keywordExistsForBrand(ctx, actor.TenantID, brandID, req.KeywordID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
return nil, response.ErrNotFound(40421, "keyword_not_found", "keyword not found")
|
|
}
|
|
|
|
summary, err := s.loadBrandLibrarySummary(ctx, actor.TenantID)
|
|
if err != nil {
|
|
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 = tx.QueryRow(ctx, `
|
|
WITH usage AS (
|
|
SELECT COUNT(*)::INT AS used_questions
|
|
FROM brand_questions
|
|
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.MaxQuestions).Scan(&questionID, &ca)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
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{
|
|
ID: questionID,
|
|
BrandID: brandID,
|
|
KeywordID: req.KeywordID,
|
|
QuestionText: req.QuestionText,
|
|
Status: "active",
|
|
CreatedAt: formatBrandTime(ca),
|
|
}, nil
|
|
}
|
|
|
|
type UpdateQuestionRequest struct {
|
|
QuestionText string `json:"question_text" binding:"required"`
|
|
}
|
|
|
|
func (s *BrandService) UpdateQuestion(ctx context.Context, brandID, questionID int64, req UpdateQuestionRequest) error {
|
|
actor := auth.MustActor(ctx)
|
|
req.QuestionText = strings.TrimSpace(req.QuestionText)
|
|
if req.QuestionText == "" {
|
|
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,
|
|
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)
|
|
return nil
|
|
}
|
|
|
|
func (s *BrandService) DeleteQuestion(ctx context.Context, brandID, questionID int64) error {
|
|
actor := auth.MustActor(ctx)
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = tx.Rollback(ctx)
|
|
}()
|
|
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE brand_questions SET deleted_at = NOW(), updated_at = NOW()
|
|
WHERE id = $1 AND brand_id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
|
`, questionID, brandID, actor.TenantID)
|
|
if err != nil || tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40422, "question_not_found", "question not found")
|
|
}
|
|
|
|
if err := s.cleanupMonitoringAfterQuestionDelete(ctx, actor.TenantID, brandID, questionID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
|
return nil
|
|
}
|
|
|
|
// --- Competitor CRUD ---
|
|
|
|
type CompetitorRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Website *string `json:"website"`
|
|
Description *string `json:"description"`
|
|
ProductLinesJSON *json.RawMessage `json:"product_lines_json"`
|
|
}
|
|
|
|
type CompetitorResponse struct {
|
|
ID int64 `json:"id"`
|
|
BrandID int64 `json:"brand_id"`
|
|
Name string `json:"name"`
|
|
Website *string `json:"website"`
|
|
Description *string `json:"description"`
|
|
ProductLinesJSON *json.RawMessage `json:"product_lines_json"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func (s *BrandService) ListCompetitors(ctx context.Context, brandID int64) ([]CompetitorResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandCompetitorsCacheKey(actor.TenantID, brandID), defaultCacheTTL(), func(loadCtx context.Context) ([]CompetitorResponse, error) {
|
|
return s.loadBrandCompetitors(loadCtx, actor.TenantID, brandID)
|
|
})
|
|
}
|
|
|
|
func (s *BrandService) CreateCompetitor(ctx context.Context, brandID int64, req CompetitorRequest) (*CompetitorResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
req.Website = normalizeOptionalString(req.Website)
|
|
req.Description = normalizeOptionalString(req.Description)
|
|
if req.Name == "" {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "name is required")
|
|
}
|
|
exists, err := s.brandExists(ctx, actor.TenantID, brandID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
|
}
|
|
|
|
var plJSON []byte
|
|
if req.ProductLinesJSON != nil {
|
|
plJSON = *req.ProductLinesJSON
|
|
}
|
|
|
|
var id int64
|
|
var ca interface{}
|
|
err = s.pool.QueryRow(ctx, `
|
|
INSERT INTO competitors (tenant_id, brand_id, name, website, description, product_lines_json)
|
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at
|
|
`, actor.TenantID, brandID, req.Name, req.Website, req.Description, plJSON).Scan(&id, &ca)
|
|
if err != nil {
|
|
if isForeignKeyConstraintError(err) {
|
|
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
|
}
|
|
return nil, response.ErrInternal(50010, "create_failed", "failed to create competitor")
|
|
}
|
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
|
return &CompetitorResponse{ID: id, BrandID: brandID, Name: req.Name, Website: req.Website, Description: req.Description, ProductLinesJSON: req.ProductLinesJSON, CreatedAt: formatBrandTime(ca)}, nil
|
|
}
|
|
|
|
func (s *BrandService) UpdateCompetitor(ctx context.Context, brandID, competitorID int64, req CompetitorRequest) error {
|
|
actor := auth.MustActor(ctx)
|
|
var plJSON []byte
|
|
if req.ProductLinesJSON != nil {
|
|
plJSON = *req.ProductLinesJSON
|
|
}
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE competitors SET name = $1, website = $2, description = $3, product_lines_json = $4, updated_at = NOW()
|
|
WHERE id = $5 AND brand_id = $6 AND tenant_id = $7 AND deleted_at IS NULL
|
|
`, req.Name, req.Website, req.Description, plJSON, competitorID, brandID, actor.TenantID)
|
|
if err != nil || tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40423, "competitor_not_found", "competitor not found")
|
|
}
|
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
|
return nil
|
|
}
|
|
|
|
func (s *BrandService) DeleteCompetitor(ctx context.Context, brandID, competitorID int64) error {
|
|
actor := auth.MustActor(ctx)
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE competitors SET deleted_at = NOW(), updated_at = NOW()
|
|
WHERE id = $1 AND brand_id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
|
`, competitorID, brandID, actor.TenantID)
|
|
if err != nil || tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40423, "competitor_not_found", "competitor not found")
|
|
}
|
|
invalidateBrandCaches(ctx, s.cache, actor.TenantID, brandID)
|
|
return nil
|
|
}
|
|
|
|
func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandResponse, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
b.id,
|
|
b.name,
|
|
b.website,
|
|
b.description,
|
|
b.status,
|
|
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
|
|
FROM brand_keywords k
|
|
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
|
|
) 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)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50010, "query_failed", "failed to list brands")
|
|
}
|
|
defer rows.Close()
|
|
|
|
brands := make([]BrandResponse, 0)
|
|
for rows.Next() {
|
|
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, &item.CompetitorCount, &createdAt, &updatedAt); err != nil {
|
|
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
|
}
|
|
item.CreatedAt = formatBrandTime(createdAt)
|
|
item.UpdatedAt = formatBrandTime(updatedAt)
|
|
brands = append(brands, item)
|
|
}
|
|
return brands, nil
|
|
}
|
|
|
|
func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID int64) (*BrandResponse, bool, error) {
|
|
var item BrandResponse
|
|
var createdAt interface{}
|
|
var updatedAt interface{}
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
b.id,
|
|
b.name,
|
|
b.website,
|
|
b.description,
|
|
b.status,
|
|
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
|
|
FROM brand_keywords k
|
|
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
|
|
) 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, &item.CompetitorCount, &createdAt, &updatedAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, response.ErrInternal(50010, "query_failed", "failed to fetch brand")
|
|
}
|
|
item.CreatedAt = formatBrandTime(createdAt)
|
|
item.UpdatedAt = formatBrandTime(updatedAt)
|
|
return &item, true, nil
|
|
}
|
|
|
|
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
|
|
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")
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]KeywordResponse, 0)
|
|
for rows.Next() {
|
|
var item KeywordResponse
|
|
var createdAt interface{}
|
|
if err := rows.Scan(&item.ID, &item.BrandID, &item.Name, &item.Status, &createdAt); err != nil {
|
|
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
|
}
|
|
item.CreatedAt = formatBrandTime(createdAt)
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
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 params.KeywordID != nil {
|
|
query += ` AND q.keyword_id = $3`
|
|
args = append(args, *params.KeywordID)
|
|
}
|
|
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 {
|
|
return nil, response.ErrInternal(50010, "query_failed", "failed to list questions")
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]QuestionResponse, 0)
|
|
for rows.Next() {
|
|
var item QuestionResponse
|
|
var createdAt interface{}
|
|
if err := rows.Scan(&item.ID, &item.BrandID, &item.KeywordID, &item.QuestionText, &item.Status, &createdAt); err != nil {
|
|
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
|
}
|
|
item.CreatedAt = formatBrandTime(createdAt)
|
|
items = append(items, item)
|
|
}
|
|
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) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, brand_id, name, website, description, product_lines_json, created_at
|
|
FROM competitors WHERE brand_id = $1 AND tenant_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC
|
|
`, brandID, tenantID)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50010, "query_failed", "failed to list competitors")
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]CompetitorResponse, 0)
|
|
for rows.Next() {
|
|
var item CompetitorResponse
|
|
var createdAt interface{}
|
|
var productLinesJSON []byte
|
|
if err := rows.Scan(&item.ID, &item.BrandID, &item.Name, &item.Website, &item.Description, &productLinesJSON, &createdAt); err != nil {
|
|
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
|
}
|
|
item.CreatedAt = formatBrandTime(createdAt)
|
|
if productLinesJSON != nil {
|
|
raw := json.RawMessage(productLinesJSON)
|
|
item.ProductLinesJSON = &raw
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
type brandLibraryPlan struct {
|
|
PlanCode string
|
|
PlanName string
|
|
}
|
|
|
|
type brandLibraryUsage struct {
|
|
BrandCount int
|
|
KeywordCount int
|
|
QuestionCount int
|
|
}
|
|
|
|
func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int64) (*BrandLibrarySummaryResponse, error) {
|
|
plan, err := s.loadBrandLibraryPlan(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
usage, err := s.loadBrandLibraryUsage(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
limits := s.currentLimits()
|
|
maxBrands := limits.BrandLimitForPlan(plan.PlanCode)
|
|
maxKeywords := limits.MaxKeywords
|
|
maxQuestions := limits.QuestionLimitForPlan(plan.PlanCode)
|
|
|
|
return &BrandLibrarySummaryResponse{
|
|
PlanCode: plan.PlanCode,
|
|
PlanName: plan.PlanName,
|
|
MaxBrands: maxBrands,
|
|
UsedBrands: usage.BrandCount,
|
|
RemainingBrands: maxInt(maxBrands-usage.BrandCount, 0),
|
|
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) {
|
|
plan := &brandLibraryPlan{
|
|
PlanCode: "free",
|
|
PlanName: "",
|
|
}
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
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
|
|
AND s.status = 'active'
|
|
AND s.deleted_at IS NULL
|
|
AND p.status = 'active'
|
|
AND s.end_at > $2
|
|
ORDER BY s.start_at DESC
|
|
LIMIT 1
|
|
`, 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")
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
func (s *BrandService) loadBrandLibraryUsage(ctx context.Context, tenantID int64) (*brandLibraryUsage, error) {
|
|
usage := &brandLibraryUsage{}
|
|
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 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")
|
|
}
|
|
return usage, nil
|
|
}
|
|
|
|
func (s *BrandService) brandExists(ctx context.Context, tenantID, brandID int64) (bool, error) {
|
|
var exists bool
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM brands
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
)
|
|
`, brandID, tenantID).Scan(&exists)
|
|
if err != nil {
|
|
return false, response.ErrInternal(50010, "query_failed", "failed to load brand")
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
func (s *BrandService) keywordExistsForBrand(ctx context.Context, tenantID, brandID, keywordID int64) (bool, error) {
|
|
var exists bool
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM brand_keywords
|
|
WHERE id = $1
|
|
AND brand_id = $2
|
|
AND tenant_id = $3
|
|
AND deleted_at IS NULL
|
|
)
|
|
`, keywordID, brandID, tenantID).Scan(&exists)
|
|
if err != nil {
|
|
return false, response.ErrInternal(50010, "query_failed", "failed to load keyword")
|
|
}
|
|
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
|
|
}
|
|
|
|
func isForeignKeyConstraintError(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23503"
|
|
}
|
|
|
|
func maxInt(value, floor int) int {
|
|
if value < floor {
|
|
return floor
|
|
}
|
|
return value
|
|
}
|