2026-04-01 00:58:42 +08:00
package app
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
2026-04-02 11:38:08 +08:00
"strings"
2026-04-01 00:58:42 +08:00
"github.com/jackc/pgx/v5/pgxpool"
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-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-02 00:31:28 +08:00
pool * pgxpool . Pool
auditLogs * auditlog . AsyncWriter
2026-04-01 00:58:42 +08:00
}
2026-04-02 00:31:28 +08:00
func NewBrandService ( pool * pgxpool . Pool , auditLogs * auditlog . AsyncWriter ) * BrandService {
return & BrandService { pool : pool , auditLogs : auditLogs }
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 {
ID int64 ` json:"id" `
Name string ` json:"name" `
2026-04-02 11:38:08 +08:00
Website * string ` json:"website" `
2026-04-01 00:58:42 +08:00
Description * string ` json:"description" `
Status string ` json:"status" `
CreatedAt string ` json:"created_at" `
UpdatedAt string ` json:"updated_at" `
}
func ( s * BrandService ) List ( ctx context . Context ) ( [ ] BrandResponse , error ) {
actor := auth . MustActor ( ctx )
rows , err := s . pool . Query ( ctx , `
2026-04-02 11:38:08 +08:00
SELECT id, name, website, description, status, created_at, updated_at
2026-04-01 00:58:42 +08:00
FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
` , actor . TenantID )
if err != nil {
return nil , response . ErrInternal ( 50010 , "query_failed" , "failed to list brands" )
}
defer rows . Close ( )
var brands [ ] BrandResponse
for rows . Next ( ) {
var b BrandResponse
var ca , ua interface { }
2026-04-02 11:38:08 +08:00
if err := rows . Scan ( & b . ID , & b . Name , & b . Website , & b . Description , & b . Status , & ca , & ua ) ; err != nil {
2026-04-01 00:58:42 +08:00
return nil , response . ErrInternal ( 50010 , "scan_failed" , err . Error ( ) )
}
b . CreatedAt = fmt . Sprintf ( "%v" , ca )
b . UpdatedAt = fmt . Sprintf ( "%v" , ua )
brands = append ( brands , b )
}
if brands == nil {
brands = [ ] BrandResponse { }
}
return brands , nil
}
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-01 00:58:42 +08:00
var id int64
var ca interface { }
err := s . pool . QueryRow ( ctx , `
2026-04-02 11:38:08 +08:00
INSERT INTO brands (tenant_id, name, website, description, status)
VALUES ($1, $2, $3, $4, 'active') RETURNING id, created_at
` , actor . TenantID , req . Name , req . Website , req . Description ) . Scan ( & id , & ca )
2026-04-01 00:58:42 +08:00
if err != nil {
return nil , response . ErrConflict ( 40901 , "brand_exists" , "brand with this name already exists" )
}
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-02 11:38:08 +08:00
return & BrandResponse {
ID : id ,
Name : req . Name ,
Website : req . Website ,
Description : req . Description ,
Status : "active" ,
CreatedAt : fmt . Sprintf ( "%v" , ca ) ,
} , nil
2026-04-01 00:58:42 +08:00
}
func ( s * BrandService ) Detail ( ctx context . Context , id int64 ) ( * BrandResponse , error ) {
actor := auth . MustActor ( ctx )
var b BrandResponse
var ca , ua interface { }
err := s . pool . QueryRow ( ctx , `
2026-04-02 11:38:08 +08:00
SELECT id, name, website, description, status, created_at, updated_at
2026-04-01 00:58:42 +08:00
FROM brands WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
2026-04-02 11:38:08 +08:00
` , id , actor . TenantID ) . Scan ( & b . ID , & b . Name , & b . Website , & b . Description , & b . Status , & ca , & ua )
2026-04-01 00:58:42 +08:00
if err != nil {
return nil , response . ErrNotFound ( 40420 , "brand_not_found" , "brand not found" )
}
b . CreatedAt = fmt . Sprintf ( "%v" , ca )
b . UpdatedAt = fmt . Sprintf ( "%v" , ua )
return & b , nil
}
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-01 00:58:42 +08:00
if err != nil || tag . RowsAffected ( ) == 0 {
return response . ErrNotFound ( 40420 , "brand_not_found" , "brand not found" )
}
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 )
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-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 )
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 , actor . TenantID )
if err != nil {
return nil , response . ErrInternal ( 50010 , "query_failed" , "failed to list keywords" )
}
defer rows . Close ( )
var keywords [ ] KeywordResponse
for rows . Next ( ) {
var k KeywordResponse
var ca interface { }
if err := rows . Scan ( & k . ID , & k . BrandID , & k . Name , & k . Status , & ca ) ; err != nil {
return nil , response . ErrInternal ( 50010 , "scan_failed" , err . Error ( ) )
}
k . CreatedAt = fmt . Sprintf ( "%v" , ca )
keywords = append ( keywords , k )
}
if keywords == nil {
keywords = [ ] KeywordResponse { }
}
return keywords , nil
}
func ( s * BrandService ) CreateKeyword ( ctx context . Context , brandID int64 , req KeywordRequest ) ( * KeywordResponse , error ) {
actor := auth . MustActor ( ctx )
var id int64
var ca interface { }
err := s . pool . QueryRow ( ctx , `
INSERT INTO brand_keywords (tenant_id, brand_id, name, status) VALUES ($1, $2, $3, 'active') RETURNING id, created_at
` , actor . TenantID , brandID , req . Name ) . Scan ( & id , & ca )
if err != nil {
return nil , response . ErrConflict ( 40902 , "keyword_exists" , "keyword with this name already exists for this brand" )
}
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 )
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 || tag . RowsAffected ( ) == 0 {
return response . ErrNotFound ( 40421 , "keyword_not_found" , "keyword not found" )
}
return nil
}
func ( s * BrandService ) DeleteKeyword ( ctx context . Context , brandID , keywordID int64 ) error {
actor := auth . MustActor ( ctx )
tag , err := s . pool . 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" )
}
return nil
}
// --- Question CRUD with versioning ---
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" `
CurrentVersionID * int64 ` json:"current_version_id" `
QuestionText * string ` json:"question_text" `
VersionNo * int ` json:"version_no" `
Status string ` json:"status" `
CreatedAt string ` json:"created_at" `
}
func ( s * BrandService ) ListQuestions ( ctx context . Context , brandID int64 , keywordID * int64 ) ( [ ] QuestionResponse , error ) {
actor := auth . MustActor ( ctx )
query := `
SELECT q.id, q.brand_id, q.keyword_id, q.current_version_id, q.status, q.created_at,
v.question_text, v.version_no
FROM brand_questions q
LEFT JOIN brand_question_versions v ON v.id = q.current_version_id
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL `
args := [ ] interface { } { brandID , actor . 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 ( )
var questions [ ] QuestionResponse
for rows . Next ( ) {
var q QuestionResponse
var ca interface { }
if err := rows . Scan ( & q . ID , & q . BrandID , & q . KeywordID , & q . CurrentVersionID , & q . Status , & ca , & q . QuestionText , & q . VersionNo ) ; err != nil {
return nil , response . ErrInternal ( 50010 , "scan_failed" , err . Error ( ) )
}
q . CreatedAt = fmt . Sprintf ( "%v" , ca )
questions = append ( questions , q )
}
if questions == nil {
questions = [ ] QuestionResponse { }
}
return questions , nil
}
func ( s * BrandService ) CreateQuestion ( ctx context . Context , brandID int64 , req QuestionRequest ) ( * QuestionResponse , error ) {
actor := auth . MustActor ( ctx )
tx , err := s . pool . Begin ( ctx )
if err != nil {
return nil , fmt . Errorf ( "begin tx: %w" , err )
}
defer func ( ) {
_ = tx . Rollback ( ctx )
} ( )
var questionID int64
err = tx . QueryRow ( ctx , `
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, status) VALUES ($1, $2, $3, 'active') RETURNING id
` , actor . TenantID , brandID , req . KeywordID ) . Scan ( & questionID )
if err != nil {
return nil , response . ErrInternal ( 50010 , "create_failed" , "failed to create question" )
}
hash := fmt . Sprintf ( "%x" , sha256 . Sum256 ( [ ] byte ( req . QuestionText ) ) )
var versionID int64
err = tx . QueryRow ( ctx , `
INSERT INTO brand_question_versions (question_id, question_text, question_hash, version_no, is_active)
VALUES ($1, $2, $3, 1, true) RETURNING id
` , questionID , req . QuestionText , hash ) . Scan ( & versionID )
if err != nil {
return nil , response . ErrInternal ( 50010 , "create_version_failed" , "failed to create question version" )
}
_ , _ = tx . Exec ( ctx , ` UPDATE brand_questions SET current_version_id = $1 WHERE id = $2 ` , versionID , questionID )
if err := tx . Commit ( ctx ) ; err != nil {
return nil , fmt . Errorf ( "commit: %w" , err )
}
vno := 1
return & QuestionResponse {
ID : questionID , BrandID : brandID , KeywordID : req . KeywordID ,
CurrentVersionID : & versionID , QuestionText : & req . QuestionText , VersionNo : & vno , Status : "active" ,
} , 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 )
tx , err := s . pool . Begin ( ctx )
if err != nil {
return fmt . Errorf ( "begin tx: %w" , err )
}
defer func ( ) {
_ = tx . Rollback ( ctx )
} ( )
// Verify ownership
var exists bool
_ = tx . QueryRow ( ctx , ` SELECT EXISTS(SELECT 1 FROM brand_questions WHERE id = $1 AND brand_id = $2 AND tenant_id = $3 AND deleted_at IS NULL) ` ,
questionID , brandID , actor . TenantID ) . Scan ( & exists )
if ! exists {
return response . ErrNotFound ( 40422 , "question_not_found" , "question not found" )
}
// Deactivate current version
_ , _ = tx . Exec ( ctx , ` UPDATE brand_question_versions SET is_active = false WHERE question_id = $1 AND is_active = true ` , questionID )
// Get next version number
var maxVersion int
_ = tx . QueryRow ( ctx , ` SELECT COALESCE(MAX(version_no), 0) FROM brand_question_versions WHERE question_id = $1 ` , questionID ) . Scan ( & maxVersion )
hash := fmt . Sprintf ( "%x" , sha256 . Sum256 ( [ ] byte ( req . QuestionText ) ) )
var versionID int64
err = tx . QueryRow ( ctx , `
INSERT INTO brand_question_versions (question_id, question_text, question_hash, version_no, is_active)
VALUES ($1, $2, $3, $4, true) RETURNING id
` , questionID , req . QuestionText , hash , maxVersion + 1 ) . Scan ( & versionID )
if err != nil {
return fmt . Errorf ( "create version: %w" , err )
}
_ , _ = tx . Exec ( ctx , ` UPDATE brand_questions SET current_version_id = $1, updated_at = NOW() WHERE id = $2 ` , versionID , questionID )
return tx . Commit ( ctx )
}
func ( s * BrandService ) DeleteQuestion ( ctx context . Context , brandID , questionID int64 ) error {
actor := auth . MustActor ( ctx )
tag , err := s . pool . 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" )
}
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 )
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 , actor . TenantID )
if err != nil {
return nil , response . ErrInternal ( 50010 , "query_failed" , "failed to list competitors" )
}
defer rows . Close ( )
var comps [ ] CompetitorResponse
for rows . Next ( ) {
var c CompetitorResponse
var ca interface { }
var plJSON [ ] byte
if err := rows . Scan ( & c . ID , & c . BrandID , & c . Name , & c . Website , & c . Description , & plJSON , & ca ) ; err != nil {
return nil , response . ErrInternal ( 50010 , "scan_failed" , err . Error ( ) )
}
c . CreatedAt = fmt . Sprintf ( "%v" , ca )
if plJSON != nil {
raw := json . RawMessage ( plJSON )
c . ProductLinesJSON = & raw
}
comps = append ( comps , c )
}
if comps == nil {
comps = [ ] CompetitorResponse { }
}
return comps , nil
}
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" )
}
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" )
}
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" )
}
return nil
}