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 monitoringPool *pgxpool.Pool auditLogs *auditlog.AsyncWriter } func NewBrandService(pool, monitoringPool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *BrandService { return &BrandService{pool: pool, monitoringPool: monitoringPool, 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) if err := s.cleanupMonitoringAfterBrandDelete(ctx, actor.TenantID, id); err != nil { return err } 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) 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 } 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) 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 } 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 }