package app import ( "context" "crypto/sha256" "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 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 }