36451a613d
- Added comprehensive technical design document for AI Brand Monitoring System V5, outlining system architecture, data models, sampling strategies, and monitoring protocols. - Key changes include a shift to a sampling-based trend monitoring approach, updated data collection and storage strategies, and new metrics for performance evaluation. - Implemented migration scripts to support the flattening of brand questions and versioning of question texts, ensuring historical data integrity and version control.
488 lines
17 KiB
Go
488 lines
17 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
)
|
|
|
|
type BrandService struct {
|
|
pool *pgxpool.Pool
|
|
auditLogs *auditlog.AsyncWriter
|
|
}
|
|
|
|
func NewBrandService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *BrandService {
|
|
return &BrandService{pool: pool, auditLogs: auditLogs}
|
|
}
|
|
|
|
// --- 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"`
|
|
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, `
|
|
SELECT id, name, website, description, status, created_at, updated_at
|
|
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{}
|
|
if err := rows.Scan(&b.ID, &b.Name, &b.Website, &b.Description, &b.Status, &ca, &ua); err != nil {
|
|
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)
|
|
normalizeBrandRequest(&req)
|
|
if req.Name == "" {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "name is required")
|
|
}
|
|
var id int64
|
|
var ca interface{}
|
|
err := s.pool.QueryRow(ctx, `
|
|
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)
|
|
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})
|
|
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,
|
|
})
|
|
|
|
return &BrandResponse{
|
|
ID: id,
|
|
Name: req.Name,
|
|
Website: req.Website,
|
|
Description: req.Description,
|
|
Status: "active",
|
|
CreatedAt: fmt.Sprintf("%v", ca),
|
|
}, nil
|
|
}
|
|
|
|
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, `
|
|
SELECT id, name, website, description, status, created_at, updated_at
|
|
FROM brands WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
`, id, actor.TenantID).Scan(&b.ID, &b.Name, &b.Website, &b.Description, &b.Status, &ca, &ua)
|
|
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)
|
|
normalizeBrandRequest(&req)
|
|
if req.Name == "" {
|
|
return response.ErrBadRequest(40001, "invalid_params", "name 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 || tag.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40420, "brand_not_found", "brand not found")
|
|
}
|
|
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 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"})
|
|
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,
|
|
})
|
|
|
|
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)
|
|
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 ---
|
|
|
|
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"`
|
|
}
|
|
|
|
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.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, 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.QuestionText, &q.Status, &ca); 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)
|
|
req.QuestionText = strings.TrimSpace(req.QuestionText)
|
|
if req.QuestionText == "" {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "question_text is required")
|
|
}
|
|
|
|
var questionID int64
|
|
var ca interface{}
|
|
err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status)
|
|
VALUES ($1, $2, $3, $4, 'active')
|
|
RETURNING id, created_at
|
|
`, actor.TenantID, brandID, req.KeywordID, req.QuestionText).Scan(&questionID, &ca)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50010, "create_failed", "failed to create question")
|
|
}
|
|
|
|
return &QuestionResponse{
|
|
ID: questionID,
|
|
BrandID: brandID,
|
|
KeywordID: req.KeywordID,
|
|
QuestionText: req.QuestionText,
|
|
Status: "active",
|
|
CreatedAt: fmt.Sprintf("%v", 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")
|
|
}
|
|
|
|
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 {
|
|
return response.ErrNotFound(40422, "question_not_found", "question not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|