feat(brand): add brand sort order and drag-to-reorder

Add a sort_order column to brands with a migration, expose a
PUT /api/tenant/brands/order reorder endpoint, and wire the
admin-web BrandsView with drag-and-drop reordering plus i18n.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 21:48:20 +08:00
parent fbc69c01b0
commit 71233b6715
16 changed files with 608 additions and 31 deletions
+132 -7
View File
@@ -73,6 +73,7 @@ type BrandResponse struct {
Website *string `json:"website"`
Description *string `json:"description"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
KeywordCount int `json:"keyword_count"`
QuestionCount int `json:"question_count"`
CompetitorCount int `json:"competitor_count"`
@@ -80,6 +81,10 @@ type BrandResponse struct {
UpdatedAt string `json:"updated_at"`
}
type BrandReorderRequest struct {
BrandIDs []int64 `json:"brand_ids" binding:"required"`
}
type BrandLibrarySummaryResponse struct {
PlanCode string `json:"plan_code"`
PlanName string `json:"plan_name"`
@@ -155,6 +160,7 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
}()
var id int64
var sortOrder int
var ca interface{}
err = tx.QueryRow(ctx, `
WITH usage AS (
@@ -162,12 +168,22 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
FROM brands
WHERE tenant_id = $1 AND deleted_at IS NULL
)
INSERT INTO brands (tenant_id, name, website, description, status)
SELECT $1, $2, $3, $4, 'active'
INSERT INTO brands (tenant_id, name, website, description, status, sort_order)
SELECT
$1,
$2,
$3,
$4,
'active',
COALESCE((
SELECT MAX(sort_order) + 1000
FROM brands
WHERE tenant_id = $1 AND deleted_at IS NULL
), 1000)
FROM usage
WHERE usage.used_brands < $5
RETURNING id, created_at
`, actor.TenantID, req.Name, req.Website, req.Description, summary.MaxBrands).Scan(&id, &ca)
RETURNING id, sort_order, created_at
`, actor.TenantID, req.Name, req.Website, req.Description, summary.MaxBrands).Scan(&id, &sortOrder, &ca)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrConflict(40904, "brand_limit_reached", fmt.Sprintf("current plan allows up to %d brand companies", summary.MaxBrands))
@@ -208,6 +224,7 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
Website: req.Website,
Description: req.Description,
Status: "active",
SortOrder: sortOrder,
KeywordCount: 0,
QuestionCount: 0,
CompetitorCount: 0,
@@ -215,6 +232,112 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
}, nil
}
func (s *BrandService) Reorder(ctx context.Context, req BrandReorderRequest) ([]BrandResponse, error) {
actor := auth.MustActor(ctx)
brandIDs, err := normalizeBrandOrderIDs(req.BrandIDs)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50010, "reorder_failed", "failed to begin brand reorder transaction")
}
defer func() {
_ = tx.Rollback(ctx)
}()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, fmt.Sprintf("tenant:%d", actor.TenantID), "brand_order"); err != nil {
return nil, response.ErrInternal(50010, "reorder_failed", "failed to lock brand order")
}
rows, err := tx.Query(ctx, `
SELECT id
FROM brands
WHERE tenant_id = $1 AND deleted_at IS NULL
ORDER BY sort_order ASC, created_at DESC, id DESC
FOR UPDATE
`, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50010, "reorder_failed", "failed to load current brand order")
}
activeBrandIDs := make([]int64, 0, len(brandIDs))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, response.ErrInternal(50010, "reorder_failed", "failed to scan current brand order")
}
activeBrandIDs = append(activeBrandIDs, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, response.ErrInternal(50010, "reorder_failed", "failed to iterate current brand order")
}
rows.Close()
if !sameIDSet(brandIDs, activeBrandIDs) {
return nil, response.ErrBadRequest(40003, "brand_order_stale", "brand order must include all active brands")
}
for index, brandID := range brandIDs {
tag, err := tx.Exec(ctx, `
UPDATE brands
SET sort_order = $1,
updated_at = NOW()
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
`, (index+1)*1000, brandID, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50010, "reorder_failed", "failed to update brand order")
}
if tag.RowsAffected() == 0 {
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
}
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50010, "reorder_failed", "failed to commit brand order")
}
invalidateBrandCaches(ctx, s.cache, actor.TenantID, 0)
return s.loadBrands(ctx, actor.TenantID)
}
func normalizeBrandOrderIDs(values []int64) ([]int64, error) {
if len(values) == 0 {
return nil, response.ErrBadRequest(40001, "invalid_params", "brand_ids is required")
}
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, id := range values {
if id <= 0 {
return nil, response.ErrBadRequest(40001, "invalid_params", "brand_ids must contain positive integers")
}
if _, exists := seen[id]; exists {
return nil, response.ErrBadRequest(40001, "invalid_params", "brand_ids must not contain duplicates")
}
seen[id] = struct{}{}
result = append(result, id)
}
return result, nil
}
func sameIDSet(left []int64, right []int64) bool {
if len(left) != len(right) {
return false
}
seen := make(map[int64]struct{}, len(left))
for _, id := range left {
seen[id] = struct{}{}
}
for _, id := range right {
if _, ok := seen[id]; !ok {
return false
}
}
return true
}
func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, error) {
actor := auth.MustActor(ctx)
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, brandDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*BrandResponse, bool, error) {
@@ -799,6 +922,7 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
b.website,
b.description,
b.status,
b.sort_order,
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
COALESCE(question_stats.question_count, 0) AS question_count,
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
@@ -829,7 +953,7 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
AND c.deleted_at IS NULL
) competitor_stats ON true
WHERE b.tenant_id = $1 AND b.deleted_at IS NULL
ORDER BY b.created_at DESC
ORDER BY b.sort_order ASC, b.created_at DESC, b.id DESC
`, tenantID)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to list brands")
@@ -841,7 +965,7 @@ func (s *BrandService) loadBrands(ctx context.Context, tenantID int64) ([]BrandR
var item BrandResponse
var createdAt interface{}
var updatedAt interface{}
if err := rows.Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt); err != nil {
if err := rows.Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.SortOrder, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
item.CreatedAt = formatBrandTime(createdAt)
@@ -862,6 +986,7 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in
b.website,
b.description,
b.status,
b.sort_order,
COALESCE(keyword_stats.keyword_count, 0) AS keyword_count,
COALESCE(question_stats.question_count, 0) AS question_count,
COALESCE(competitor_stats.competitor_count, 0) AS competitor_count,
@@ -892,7 +1017,7 @@ func (s *BrandService) loadBrandDetail(ctx context.Context, tenantID, brandID in
AND c.deleted_at IS NULL
) competitor_stats ON true
WHERE b.id = $1 AND b.tenant_id = $2 AND b.deleted_at IS NULL
`, brandID, tenantID).Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt)
`, brandID, tenantID).Scan(&item.ID, &item.Name, &item.Website, &item.Description, &item.Status, &item.SortOrder, &item.KeywordCount, &item.QuestionCount, &item.CompetitorCount, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil