2026-04-01 00:58:42 +08:00
package app
import (
"context"
"encoding/json"
2026-04-15 16:11:05 +08:00
"errors"
2026-04-01 00:58:42 +08:00
"fmt"
2026-04-02 11:38:08 +08:00
"strings"
2026-05-01 16:01:23 +08:00
"sync"
2026-04-18 20:56:05 +08:00
"time"
2026-04-01 00:58:42 +08:00
2026-04-15 16:11:05 +08:00
"github.com/jackc/pgx/v5"
2026-04-16 21:01:40 +08:00
"github.com/jackc/pgx/v5/pgconn"
2026-04-01 00:58:42 +08:00
"github.com/jackc/pgx/v5/pgxpool"
2026-04-15 16:11:05 +08:00
"golang.org/x/sync/singleflight"
2026-04-01 00:58:42 +08:00
2026-04-02 00:31:28 +08:00
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
2026-04-01 00:58:42 +08:00
"github.com/geo-platform/tenant-api/internal/shared/auth"
2026-04-15 16:11:05 +08:00
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
2026-04-16 21:01:40 +08:00
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
2026-04-02 00:31:28 +08:00
"github.com/geo-platform/tenant-api/internal/shared/middleware"
2026-04-01 00:58:42 +08:00
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type BrandService struct {
2026-04-12 09:56:18 +08:00
pool * pgxpool . Pool
monitoringPool * pgxpool . Pool
2026-05-01 16:01:23 +08:00
limitsMu sync . RWMutex
2026-04-16 21:01:40 +08:00
limits sharedconfig . BrandLibraryConfig
2026-04-12 09:56:18 +08:00
auditLogs * auditlog . AsyncWriter
2026-04-15 16:11:05 +08:00
cache sharedcache . Cache
cacheGroup singleflight . Group
2026-04-01 00:58:42 +08:00
}
2026-04-16 21:01:40 +08:00
func NewBrandService ( pool , monitoringPool * pgxpool . Pool , auditLogs * auditlog . AsyncWriter , limits sharedconfig . BrandLibraryConfig ) * BrandService {
return & BrandService { pool : pool , monitoringPool : monitoringPool , limits : limits , auditLogs : auditLogs }
2026-04-01 00:58:42 +08:00
}
2026-04-15 16:11:05 +08:00
func ( s * BrandService ) WithCache ( c sharedcache . Cache ) * BrandService {
s . cache = c
return s
}
2026-05-01 16:01:23 +08:00
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
}
2026-04-01 00:58:42 +08:00
// --- Brand CRUD ---
type BrandRequest struct {
Name string ` json:"name" binding:"required" `
2026-04-02 11:38:08 +08:00
Website * string ` json:"website" `
2026-04-01 00:58:42 +08:00
Description * string ` json:"description" `
}
type BrandResponse struct {
2026-04-16 21:01:40 +08:00
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" `
}
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" `
MaxQuestionsPerKeyword int ` json:"max_questions_per_keyword" `
2026-04-01 00:58:42 +08:00
}
func ( s * BrandService ) List ( ctx context . Context ) ( [ ] BrandResponse , error ) {
actor := auth . MustActor ( ctx )
2026-04-15 16:11:05 +08:00
return sharedcache . LoadJSON ( ctx , s . cache , & s . cacheGroup , brandListCacheKey ( actor . TenantID ) , defaultCacheTTL ( ) , func ( loadCtx context . Context ) ( [ ] BrandResponse , error ) {
return s . loadBrands ( loadCtx , actor . TenantID )
} )
2026-04-01 00:58:42 +08:00
}
2026-04-16 21:01:40 +08:00
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 )
} )
}
2026-04-01 00:58:42 +08:00
func ( s * BrandService ) Create ( ctx context . Context , req BrandRequest ) ( * BrandResponse , error ) {
actor := auth . MustActor ( ctx )
2026-04-02 11:38:08 +08:00
normalizeBrandRequest ( & req )
if req . Name == "" {
return nil , response . ErrBadRequest ( 40001 , "invalid_params" , "name is required" )
}
2026-04-16 21:01:40 +08:00
summary , err := s . loadBrandLibrarySummary ( ctx , actor . TenantID )
if err != nil {
return nil , err
}
2026-04-01 00:58:42 +08:00
var id int64
var ca interface { }
2026-04-16 21:01:40 +08:00
err = s . pool . QueryRow ( ctx , `
WITH usage AS (
SELECT COUNT(*)::INT AS used_brands
FROM brands
WHERE tenant_id = $1 AND deleted_at IS NULL
)
2026-04-02 11:38:08 +08:00
INSERT INTO brands (tenant_id, name, website, description, status)
2026-04-16 21:01:40 +08:00
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 )
2026-04-01 00:58:42 +08:00
if err != nil {
2026-04-16 21:01:40 +08:00
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" )
2026-04-01 00:58:42 +08:00
}
afterJSON , _ := json . Marshal ( map [ string ] interface { } { "id" : id , "name" : req . Name } )
2026-04-02 00:31:28 +08:00
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 ,
} )
2026-04-01 00:58:42 +08:00
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , id )
2026-04-02 11:38:08 +08:00
return & BrandResponse {
2026-04-16 21:01:40 +08:00
ID : id ,
Name : req . Name ,
Website : req . Website ,
Description : req . Description ,
Status : "active" ,
KeywordCount : 0 ,
QuestionCount : 0 ,
CreatedAt : fmt . Sprintf ( "%v" , ca ) ,
2026-04-02 11:38:08 +08:00
} , nil
2026-04-01 00:58:42 +08:00
}
func ( s * BrandService ) Detail ( ctx context . Context , id int64 ) ( * BrandResponse , error ) {
actor := auth . MustActor ( ctx )
2026-04-15 16:11:05 +08:00
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 )
} )
2026-04-01 00:58:42 +08:00
if err != nil {
2026-04-15 16:11:05 +08:00
return nil , err
}
if ! found || record == nil {
2026-04-01 00:58:42 +08:00
return nil , response . ErrNotFound ( 40420 , "brand_not_found" , "brand not found" )
}
2026-04-15 16:11:05 +08:00
return record , nil
2026-04-01 00:58:42 +08:00
}
func ( s * BrandService ) Update ( ctx context . Context , id int64 , req BrandRequest ) error {
actor := auth . MustActor ( ctx )
2026-04-02 11:38:08 +08:00
normalizeBrandRequest ( & req )
if req . Name == "" {
return response . ErrBadRequest ( 40001 , "invalid_params" , "name is required" )
}
2026-04-01 00:58:42 +08:00
tag , err := s . pool . Exec ( ctx , `
2026-04-02 11:38:08 +08:00
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 )
2026-04-16 21:01:40 +08:00
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 {
2026-04-01 00:58:42 +08:00
return response . ErrNotFound ( 40420 , "brand_not_found" , "brand not found" )
}
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , id )
2026-04-01 00:58:42 +08:00
return nil
}
2026-04-02 11:38:08 +08:00
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 )
}
2026-04-01 00:58:42 +08:00
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 deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL ` , id , actor . TenantID )
if err != nil || tag . RowsAffected ( ) == 0 {
return response . ErrNotFound ( 40420 , "brand_not_found" , "brand not found" )
}
_ , _ = 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 )
2026-04-12 09:56:18 +08:00
if err := s . cleanupMonitoringAfterBrandDelete ( ctx , actor . TenantID , id ) ; err != nil {
return err
}
2026-04-01 00:58:42 +08:00
afterJSON , _ := json . Marshal ( map [ string ] interface { } { "id" : id , "action" : "cascade_soft_delete" } )
2026-04-02 00:31:28 +08:00
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 ,
} )
2026-04-01 00:58:42 +08:00
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , id )
2026-04-02 00:31:28 +08:00
return nil
2026-04-01 00:58:42 +08:00
}
// --- 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 )
2026-04-15 16:11:05 +08:00
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 )
} )
2026-04-01 00:58:42 +08:00
}
func ( s * BrandService ) CreateKeyword ( ctx context . Context , brandID int64 , req KeywordRequest ) ( * KeywordResponse , error ) {
actor := auth . MustActor ( ctx )
2026-04-16 21:01:40 +08:00
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
}
2026-04-01 00:58:42 +08:00
var id int64
var ca interface { }
2026-04-16 21:01:40 +08:00
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 )
2026-04-01 00:58:42 +08:00
if err != nil {
2026-04-16 21:01:40 +08:00
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" )
2026-04-01 00:58:42 +08:00
}
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , brandID )
2026-04-01 00:58:42 +08:00
return & KeywordResponse { ID : id , BrandID : brandID , Name : req . Name , Status : "active" , CreatedAt : fmt . Sprintf ( "%v" , ca ) } , nil
}
func ( s * BrandService ) UpdateKeyword ( ctx context . Context , brandID , keywordID int64 , req KeywordRequest ) error {
actor := auth . MustActor ( ctx )
2026-04-16 21:01:40 +08:00
req . Name = strings . TrimSpace ( req . Name )
if req . Name == "" {
return response . ErrBadRequest ( 40001 , "invalid_params" , "name is required" )
}
2026-04-01 00:58:42 +08:00
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 )
2026-04-16 21:01:40 +08:00
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 {
2026-04-01 00:58:42 +08:00
return response . ErrNotFound ( 40421 , "keyword_not_found" , "keyword not found" )
}
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , brandID )
2026-04-01 00:58:42 +08:00
return nil
}
func ( s * BrandService ) DeleteKeyword ( ctx context . Context , brandID , keywordID int64 ) error {
actor := auth . MustActor ( ctx )
2026-04-12 09:56:18 +08:00
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 , `
2026-04-01 00:58:42 +08:00
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" )
}
2026-04-12 09:56:18 +08:00
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
}
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , brandID )
2026-04-01 00:58:42 +08:00
return nil
}
2026-04-09 14:43:20 +08:00
// --- Question CRUD ---
2026-04-01 00:58:42 +08:00
type QuestionRequest struct {
KeywordID int64 ` json:"keyword_id" binding:"required" `
QuestionText string ` json:"question_text" binding:"required" `
}
type QuestionResponse struct {
2026-04-09 14:43:20 +08:00
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" `
2026-04-01 00:58:42 +08:00
}
func ( s * BrandService ) ListQuestions ( ctx context . Context , brandID int64 , keywordID * int64 ) ( [ ] QuestionResponse , error ) {
actor := auth . MustActor ( ctx )
2026-04-15 16:11:05 +08:00
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 )
} )
2026-04-01 00:58:42 +08:00
}
func ( s * BrandService ) CreateQuestion ( ctx context . Context , brandID int64 , req QuestionRequest ) ( * QuestionResponse , error ) {
actor := auth . MustActor ( ctx )
2026-04-16 21:01:40 +08:00
if req . KeywordID <= 0 {
return nil , response . ErrBadRequest ( 40001 , "invalid_params" , "keyword_id is required" )
}
2026-04-09 14:43:20 +08:00
req . QuestionText = strings . TrimSpace ( req . QuestionText )
if req . QuestionText == "" {
return nil , response . ErrBadRequest ( 40001 , "invalid_params" , "question_text is required" )
2026-04-01 00:58:42 +08:00
}
2026-04-16 21:01:40 +08:00
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
}
2026-04-01 00:58:42 +08:00
var questionID int64
2026-04-09 14:43:20 +08:00
var ca interface { }
2026-04-16 21:01:40 +08:00
err = s . pool . 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
)
2026-04-09 14:43:20 +08:00
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status)
2026-04-16 21:01:40 +08:00
SELECT $1, $2, $3, $4, 'active'
FROM usage
WHERE usage.used_questions < $5
2026-04-09 14:43:20 +08:00
RETURNING id, created_at
2026-04-16 21:01:40 +08:00
` , actor . TenantID , brandID , req . KeywordID , req . QuestionText , summary . MaxQuestionsPerKeyword ) . Scan ( & questionID , & ca )
2026-04-01 00:58:42 +08:00
if err != nil {
2026-04-16 21:01:40 +08:00
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 ) )
}
if isForeignKeyConstraintError ( err ) {
return nil , response . ErrNotFound ( 40421 , "keyword_not_found" , "keyword not found" )
}
2026-04-01 00:58:42 +08:00
return nil , response . ErrInternal ( 50010 , "create_failed" , "failed to create question" )
}
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , brandID )
2026-04-01 00:58:42 +08:00
return & QuestionResponse {
2026-04-09 14:43:20 +08:00
ID : questionID ,
BrandID : brandID ,
KeywordID : req . KeywordID ,
QuestionText : req . QuestionText ,
Status : "active" ,
CreatedAt : fmt . Sprintf ( "%v" , ca ) ,
2026-04-01 00:58:42 +08:00
} , 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 )
2026-04-09 14:43:20 +08:00
req . QuestionText = strings . TrimSpace ( req . QuestionText )
if req . QuestionText == "" {
return response . ErrBadRequest ( 40001 , "invalid_params" , "question_text is required" )
2026-04-01 00:58:42 +08:00
}
2026-04-09 14:43:20 +08:00
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 {
2026-04-01 00:58:42 +08:00
return response . ErrNotFound ( 40422 , "question_not_found" , "question not found" )
}
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , brandID )
2026-04-09 14:43:20 +08:00
return nil
2026-04-01 00:58:42 +08:00
}
func ( s * BrandService ) DeleteQuestion ( ctx context . Context , brandID , questionID int64 ) error {
actor := auth . MustActor ( ctx )
2026-04-12 09:56:18 +08:00
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 , `
2026-04-01 00:58:42 +08:00
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" )
}
2026-04-12 09:56:18 +08:00
if err := s . cleanupMonitoringAfterQuestionDelete ( ctx , actor . TenantID , brandID , questionID ) ; err != nil {
return err
}
if err := tx . Commit ( ctx ) ; err != nil {
return err
}
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , brandID )
2026-04-01 00:58:42 +08:00
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 )
2026-04-15 16:11:05 +08:00
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 )
} )
2026-04-01 00:58:42 +08:00
}
func ( s * BrandService ) CreateCompetitor ( ctx context . Context , brandID int64 , req CompetitorRequest ) ( * CompetitorResponse , error ) {
actor := auth . MustActor ( ctx )
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 {
return nil , response . ErrInternal ( 50010 , "create_failed" , "failed to create competitor" )
}
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , brandID )
2026-04-01 00:58:42 +08:00
return & CompetitorResponse { ID : id , BrandID : brandID , Name : req . Name , Website : req . Website , Description : req . Description , ProductLinesJSON : req . ProductLinesJSON , CreatedAt : fmt . Sprintf ( "%v" , 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" )
}
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , brandID )
2026-04-01 00:58:42 +08:00
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" )
}
2026-04-15 16:11:05 +08:00
invalidateBrandCaches ( ctx , s . cache , actor . TenantID , brandID )
2026-04-01 00:58:42 +08:00
return nil
}
2026-04-15 16:11:05 +08:00
func ( s * BrandService ) loadBrands ( ctx context . Context , tenantID int64 ) ( [ ] BrandResponse , error ) {
rows , err := s . pool . Query ( ctx , `
2026-04-16 21:01:40 +08:00
SELECT
b.id,
b.name,
b.website,
b.description,
b.status,
COALESCE(stats.keyword_count, 0) AS keyword_count,
COALESCE(stats.question_count, 0) AS question_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
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 k.deleted_at IS NULL
) stats ON true
WHERE b.tenant_id = $1 AND b.deleted_at IS NULL
ORDER BY b.created_at DESC
2026-04-15 16:11:05 +08:00
` , 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 { }
2026-04-16 21:01:40 +08:00
if err := rows . Scan ( & item . ID , & item . Name , & item . Website , & item . Description , & item . Status , & item . KeywordCount , & item . QuestionCount , & createdAt , & updatedAt ) ; err != nil {
2026-04-15 16:11:05 +08:00
return nil , response . ErrInternal ( 50010 , "scan_failed" , err . Error ( ) )
}
item . CreatedAt = fmt . Sprintf ( "%v" , createdAt )
item . UpdatedAt = fmt . Sprintf ( "%v" , 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 , `
2026-04-16 21:01:40 +08:00
SELECT
b.id,
b.name,
b.website,
b.description,
b.status,
COALESCE(stats.keyword_count, 0) AS keyword_count,
COALESCE(stats.question_count, 0) AS question_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
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 k.deleted_at IS NULL
) 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 )
2026-04-15 16:11:05 +08:00
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 = fmt . Sprintf ( "%v" , createdAt )
item . UpdatedAt = fmt . Sprintf ( "%v" , 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 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 = fmt . Sprintf ( "%v" , createdAt )
items = append ( items , item )
}
return items , nil
}
func ( s * BrandService ) loadBrandQuestions ( ctx context . Context , tenantID , brandID int64 , keywordID * int64 ) ( [ ] QuestionResponse , error ) {
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 {
query += ` AND q.keyword_id = $3 `
args = append ( args , * keywordID )
}
query += ` ORDER BY q.created_at DESC `
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 = fmt . Sprintf ( "%v" , createdAt )
items = append ( items , item )
}
return items , 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 = fmt . Sprintf ( "%v" , createdAt )
if productLinesJSON != nil {
raw := json . RawMessage ( productLinesJSON )
item . ProductLinesJSON = & raw
}
items = append ( items , item )
}
return items , nil
}
2026-04-16 21:01:40 +08:00
type brandLibraryPlan struct {
2026-04-18 20:56:05 +08:00
PlanCode string
PlanName string
MaxBrands int
2026-04-16 21:01:40 +08:00
}
type brandLibraryUsage struct {
BrandCount int
KeywordCount 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
}
2026-05-01 16:01:23 +08:00
limits := s . currentLimits ( )
maxBrands := limits . BrandLimitForPlan ( plan . PlanCode )
2026-04-18 20:56:05 +08:00
if plan . MaxBrands > 0 {
maxBrands = plan . MaxBrands
}
2026-05-01 16:01:23 +08:00
maxKeywords := limits . MaxKeywords
2026-04-16 21:01:40 +08:00
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 ) ,
2026-05-01 16:01:23 +08:00
MaxQuestionsPerKeyword : limits . MaxQuestionsPerKeyword ,
2026-04-16 21:01:40 +08:00
} , nil
}
func ( s * BrandService ) loadBrandLibraryPlan ( ctx context . Context , tenantID int64 ) ( * brandLibraryPlan , error ) {
2026-05-01 16:01:23 +08:00
limits := s . currentLimits ( )
2026-04-16 21:01:40 +08:00
plan := & brandLibraryPlan {
2026-04-18 20:56:05 +08:00
PlanCode : "free" ,
PlanName : "" ,
2026-05-01 16:01:23 +08:00
MaxBrands : limits . BrandLimitForPlan ( "free" ) ,
2026-04-16 21:01:40 +08:00
}
2026-04-18 20:56:05 +08:00
var quotaPolicyJSON [ ] byte
2026-04-16 21:01:40 +08:00
err := s . pool . QueryRow ( ctx , `
2026-04-18 20:56:05 +08:00
SELECT p.plan_code, p.name, p.quota_policy_json
2026-04-16 21:01:40 +08:00
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
2026-04-18 20:56:05 +08:00
AND p.status = 'active'
AND s.end_at > $2
2026-04-16 21:01:40 +08:00
ORDER BY s.start_at DESC
LIMIT 1
2026-04-18 20:56:05 +08:00
` , tenantID , time . Now ( ) . UTC ( ) ) . Scan ( & plan . PlanCode , & plan . PlanName , & quotaPolicyJSON )
2026-04-16 21:01:40 +08:00
if err != nil {
if errors . Is ( err , pgx . ErrNoRows ) {
return plan , nil
}
return nil , response . ErrInternal ( 50010 , "query_failed" , "failed to load active plan" )
}
2026-04-18 20:56:05 +08:00
var quotaPolicy struct {
BrandLimit int ` json:"brand_limit" `
}
if len ( quotaPolicyJSON ) > 0 {
_ = json . Unmarshal ( quotaPolicyJSON , & quotaPolicy )
}
if quotaPolicy . BrandLimit > 0 {
plan . MaxBrands = quotaPolicy . BrandLimit
}
2026-04-16 21:01:40 +08:00
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) AS keyword_count
` , tenantID ) . Scan ( & usage . BrandCount , & usage . KeywordCount )
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 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
}