feat: scope articles by brand
This commit is contained in:
@@ -134,6 +134,7 @@ const articleEffectivePublishStatusExpr = `CASE
|
||||
type ArticleListParams struct {
|
||||
Page int
|
||||
PageSize int
|
||||
BrandID int64
|
||||
GenerateStatus *string
|
||||
PublishStatus *string
|
||||
SourceType *string
|
||||
@@ -148,6 +149,7 @@ type ArticleListParams struct {
|
||||
|
||||
type ArticleListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
KolPromptID *int64 `json:"kol_prompt_id"`
|
||||
@@ -176,14 +178,20 @@ type ArticleListResponse struct {
|
||||
|
||||
func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*ArticleListResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandID, err := requireCurrentBrandID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params.BrandID = brandID
|
||||
version := articleCacheVersion(ctx, s.cache, actor.TenantID)
|
||||
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, articleListCacheKey(actor.TenantID, version, params), defaultCacheTTL(), func(loadCtx context.Context) (*ArticleListResponse, error) {
|
||||
return s.loadArticles(loadCtx, actor.TenantID, params)
|
||||
return s.loadArticles(loadCtx, actor.TenantID, brandID, params)
|
||||
})
|
||||
}
|
||||
|
||||
type ArticleDetailResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
CurrentVersionID *int64 `json:"current_version_id"`
|
||||
@@ -209,9 +217,13 @@ type ArticleDetailResponse struct {
|
||||
|
||||
func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandID, err := requireCurrentBrandID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version := articleCacheVersion(ctx, s.cache, actor.TenantID)
|
||||
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, articleDetailCacheKey(actor.TenantID, id, version), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*ArticleDetailResponse, bool, error) {
|
||||
return s.loadArticleDetail(loadCtx, actor.TenantID, id)
|
||||
return s.loadArticleDetail(loadCtx, actor.TenantID, brandID, id)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -222,7 +234,7 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, params ArticleListParams) (*ArticleListResponse, error) {
|
||||
func (s *ArticleService) loadArticles(ctx context.Context, tenantID, brandID int64, params ArticleListParams) (*ArticleListResponse, error) {
|
||||
sourceTypes := normalizeArticleSourceTypes(params.SourceType)
|
||||
|
||||
if params.Page < 1 {
|
||||
@@ -234,7 +246,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
||||
offset := (params.Page - 1) * params.PageSize
|
||||
|
||||
var total int64
|
||||
countArgs := []interface{}{tenantID}
|
||||
countArgs := []interface{}{tenantID, brandID}
|
||||
countQuery := `SELECT COUNT(*) FROM articles a
|
||||
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
||||
LEFT JOIN prompt_rules pr ON pr.id = a.prompt_rule_id
|
||||
@@ -249,8 +261,8 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
||||
countQuery += articlePublishStatusAggregateJoin
|
||||
}
|
||||
countQuery += `
|
||||
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
|
||||
argIdx := 2
|
||||
WHERE a.tenant_id = $1 AND a.brand_id = $2 AND a.deleted_at IS NULL`
|
||||
argIdx := 3
|
||||
|
||||
if params.GenerateStatus != nil {
|
||||
countQuery += ` AND a.generate_status = $` + strconv.Itoa(argIdx)
|
||||
@@ -307,7 +319,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to count articles")
|
||||
}
|
||||
|
||||
listQuery := `SELECT a.id, a.source_type, a.template_id, a.kol_prompt_id, a.prompt_rule_id, a.generate_status, ` + articleEffectivePublishStatusExpr + `, a.created_at,
|
||||
listQuery := `SELECT a.id, a.brand_id, a.source_type, a.template_id, a.kol_prompt_id, a.prompt_rule_id, a.generate_status, ` + articleEffectivePublishStatusExpr + `, a.created_at,
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, COALESCE(av.word_count, 0), av.source_label,
|
||||
COALESCE(t.template_name, NULLIF(gt.input_params_json ->> 'prompt_name', ''), NULLIF(gt.input_params_json ->> 'package_name', '')), pr.name, a.wizard_state_json, gt.input_params_json
|
||||
FROM articles a
|
||||
@@ -322,9 +334,9 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
||||
LIMIT 1
|
||||
) gt ON true
|
||||
` + articlePublishStatusAggregateJoin + `
|
||||
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
|
||||
listArgs := []interface{}{tenantID}
|
||||
listIdx := 2
|
||||
WHERE a.tenant_id = $1 AND a.brand_id = $2 AND a.deleted_at IS NULL`
|
||||
listArgs := []interface{}{tenantID, brandID}
|
||||
listIdx := 3
|
||||
|
||||
if params.GenerateStatus != nil {
|
||||
listQuery += ` AND a.generate_status = $` + strconv.Itoa(listIdx)
|
||||
@@ -392,7 +404,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
||||
var dbSourceType string
|
||||
var wizardStateJSON []byte
|
||||
var inputParamsJSON []byte
|
||||
if err := rows.Scan(&item.ID, &dbSourceType, &item.TemplateID, &item.KolPromptID, &item.PromptRuleID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
|
||||
if err := rows.Scan(&item.ID, &item.BrandID, &dbSourceType, &item.TemplateID, &item.KolPromptID, &item.PromptRuleID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
|
||||
&item.Title, &item.GenerationErrorMessage, &item.WordCount, &item.SourceLabel, &item.TemplateName, &item.PromptRuleName, &wizardStateJSON, &inputParamsJSON); err != nil {
|
||||
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
|
||||
}
|
||||
@@ -409,14 +421,14 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
||||
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
|
||||
}
|
||||
|
||||
func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articleID int64) (*ArticleDetailResponse, bool, error) {
|
||||
func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, brandID, articleID int64) (*ArticleDetailResponse, bool, error) {
|
||||
var item ArticleDetailResponse
|
||||
var dbSourceType string
|
||||
var currentVersionID *int64
|
||||
var wizardStateJSON []byte
|
||||
var inputParamsJSON []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT a.id, a.source_type, a.template_id, a.current_version_id, a.generate_status, `+articleEffectivePublishStatusExpr+`, a.created_at,
|
||||
SELECT a.id, a.brand_id, a.source_type, a.template_id, a.current_version_id, a.generate_status, `+articleEffectivePublishStatusExpr+`, a.created_at,
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), COALESCE(av.word_count, 0), av.source_label, av.version_no,
|
||||
t.template_name, gt.error_message, a.wizard_state_json, gt.input_params_json
|
||||
FROM articles a
|
||||
@@ -430,8 +442,8 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
|
||||
LIMIT 1
|
||||
) gt ON true
|
||||
`+articlePublishStatusAggregateJoin+`
|
||||
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL
|
||||
`, articleID, tenantID).Scan(&item.ID, &dbSourceType, &item.TemplateID, ¤tVersionID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
|
||||
WHERE a.id = $1 AND a.tenant_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL
|
||||
`, articleID, tenantID, brandID).Scan(&item.ID, &item.BrandID, &dbSourceType, &item.TemplateID, ¤tVersionID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
|
||||
&item.Title, &item.WordCount, &item.SourceLabel, &item.VersionNo, &item.TemplateName, &item.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -496,6 +508,10 @@ type CreateArticleRequest struct {
|
||||
|
||||
func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (*ArticleDetailResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandID, err := requireCurrentBrandID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(req.Title)
|
||||
|
||||
@@ -507,10 +523,10 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
|
||||
|
||||
var articleID int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO articles (tenant_id, source_type, template_id, generate_status, publish_status)
|
||||
VALUES ($1, 'free_create', NULL, 'completed', 'unpublished')
|
||||
INSERT INTO articles (tenant_id, brand_id, source_type, template_id, generate_status, publish_status)
|
||||
VALUES ($1, $2, 'free_create', NULL, 'completed', 'unpublished')
|
||||
RETURNING id
|
||||
`, actor.TenantID).Scan(&articleID); err != nil {
|
||||
`, actor.TenantID, brandID).Scan(&articleID); err != nil {
|
||||
return nil, response.ErrInternal(50014, "create_failed", "failed to create article")
|
||||
}
|
||||
|
||||
@@ -561,6 +577,10 @@ type ArticleImageUploadResponse struct {
|
||||
|
||||
func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticleRequest) (*ArticleDetailResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandID, err := requireCurrentBrandID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" {
|
||||
@@ -585,9 +605,9 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT generate_status, wizard_state_json
|
||||
FROM articles
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
|
||||
FOR UPDATE
|
||||
`, id, actor.TenantID).Scan(&generateStatus, &wizardStateJSON)
|
||||
`, id, actor.TenantID, brandID).Scan(&generateStatus, &wizardStateJSON)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
@@ -656,8 +676,8 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
||||
publish_status = 'unpublished',
|
||||
wizard_state_json = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
||||
`, versionID, id, actor.TenantID, nextWizardStateJSON)
|
||||
WHERE id = $2 AND tenant_id = $3 AND brand_id = $5 AND deleted_at IS NULL
|
||||
`, versionID, id, actor.TenantID, nextWizardStateJSON, brandID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article")
|
||||
}
|
||||
@@ -700,6 +720,10 @@ func (s *ArticleService) UploadImage(
|
||||
content []byte,
|
||||
) (*ArticleImageUploadResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandID, err := requireCurrentBrandID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.objectStorage.Validate(); err != nil {
|
||||
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
@@ -730,7 +754,7 @@ func (s *ArticleService) UploadImage(
|
||||
return nil, response.ErrBadRequest(40018, "article_image_dimensions_too_large", "image pixel count exceeds the maximum allowed")
|
||||
}
|
||||
|
||||
generateStatus, err := s.getEditableArticleStatus(ctx, articleID, actor.TenantID)
|
||||
generateStatus, err := s.getEditableArticleStatus(ctx, articleID, actor.TenantID, brandID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -782,11 +806,15 @@ type VersionItem struct {
|
||||
|
||||
func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]VersionItem, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandID, err := requireCurrentBrandID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
var exists bool
|
||||
_ = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM articles WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)`,
|
||||
articleID, actor.TenantID).Scan(&exists)
|
||||
_ = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM articles WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL)`,
|
||||
articleID, actor.TenantID, brandID).Scan(&exists)
|
||||
if !exists {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
@@ -816,11 +844,15 @@ func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]Versi
|
||||
|
||||
func (s *ArticleService) Delete(ctx context.Context, id int64) error {
|
||||
actor := auth.MustActor(ctx)
|
||||
brandID, err := requireCurrentBrandID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read before_json for audit
|
||||
var beforeJSON []byte
|
||||
_ = s.pool.QueryRow(ctx, `SELECT row_to_json(a) FROM articles a WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||
id, actor.TenantID).Scan(&beforeJSON)
|
||||
_ = s.pool.QueryRow(ctx, `SELECT row_to_json(a) FROM articles a WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL`,
|
||||
id, actor.TenantID, brandID).Scan(&beforeJSON)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -830,8 +862,8 @@ func (s *ArticleService) Delete(ctx context.Context, id int64) error {
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE articles SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, id, actor.TenantID)
|
||||
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
|
||||
`, id, actor.TenantID, brandID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
@@ -1054,13 +1086,13 @@ WHERE tenant_id = $1
|
||||
return validIDs, nil
|
||||
}
|
||||
|
||||
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64) (string, error) {
|
||||
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64, brandID int64) (string, error) {
|
||||
var generateStatus string
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT generate_status
|
||||
FROM articles
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, articleID, tenantID).Scan(&generateStatus); err != nil {
|
||||
WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL
|
||||
`, articleID, tenantID, brandID).Scan(&generateStatus); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user