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:
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ type Brand struct {
|
||||
Website *string
|
||||
Description *string
|
||||
Status string
|
||||
SortOrder int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -12,26 +12,33 @@ import (
|
||||
)
|
||||
|
||||
const createBrand = `-- name: CreateBrand :one
|
||||
INSERT INTO brands (tenant_id, name, description, status)
|
||||
VALUES ($1, $2, $3, 'active')
|
||||
RETURNING id, created_at
|
||||
INSERT INTO brands (tenant_id, name, description, status, sort_order)
|
||||
VALUES ($1, $2, $3, 'active', $4)
|
||||
RETURNING id, sort_order, created_at
|
||||
`
|
||||
|
||||
type CreateBrandParams struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type CreateBrandRow struct {
|
||||
ID int64 `json:"id"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateBrand(ctx context.Context, arg CreateBrandParams) (CreateBrandRow, error) {
|
||||
row := q.db.QueryRow(ctx, createBrand, arg.TenantID, arg.Name, arg.Description)
|
||||
row := q.db.QueryRow(ctx, createBrand,
|
||||
arg.TenantID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.SortOrder,
|
||||
)
|
||||
var i CreateBrandRow
|
||||
err := row.Scan(&i.ID, &i.CreatedAt)
|
||||
err := row.Scan(&i.ID, &i.SortOrder, &i.CreatedAt)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -119,7 +126,7 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams)
|
||||
}
|
||||
|
||||
const getBrandByID = `-- name: GetBrandByID :one
|
||||
SELECT id, tenant_id, name, description, status, created_at, updated_at
|
||||
SELECT id, tenant_id, name, description, status, sort_order, created_at, updated_at
|
||||
FROM brands
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`
|
||||
@@ -135,6 +142,7 @@ type GetBrandByIDRow struct {
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
@@ -148,6 +156,7 @@ func (q *Queries) GetBrandByID(ctx context.Context, arg GetBrandByIDParams) (Get
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -155,10 +164,10 @@ func (q *Queries) GetBrandByID(ctx context.Context, arg GetBrandByIDParams) (Get
|
||||
}
|
||||
|
||||
const listBrands = `-- name: ListBrands :many
|
||||
SELECT id, tenant_id, name, description, status, created_at, updated_at
|
||||
SELECT id, tenant_id, name, description, status, sort_order, created_at, updated_at
|
||||
FROM brands
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY sort_order ASC, created_at DESC, id DESC
|
||||
`
|
||||
|
||||
type ListBrandsRow struct {
|
||||
@@ -167,6 +176,7 @@ type ListBrandsRow struct {
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
@@ -186,6 +196,7 @@ func (q *Queries) ListBrands(ctx context.Context, tenantID int64) ([]ListBrandsR
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
||||
@@ -122,6 +122,7 @@ type Brand struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||
Website pgtype.Text `json:"website"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type BrandAssetCleanupEvent struct {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
-- name: ListBrands :many
|
||||
SELECT id, tenant_id, name, description, status, created_at, updated_at
|
||||
SELECT id, tenant_id, name, description, status, sort_order, created_at, updated_at
|
||||
FROM brands
|
||||
WHERE tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC;
|
||||
ORDER BY sort_order ASC, created_at DESC, id DESC;
|
||||
|
||||
-- name: GetBrandByID :one
|
||||
SELECT id, tenant_id, name, description, status, created_at, updated_at
|
||||
SELECT id, tenant_id, name, description, status, sort_order, created_at, updated_at
|
||||
FROM brands
|
||||
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL;
|
||||
|
||||
-- name: CreateBrand :one
|
||||
INSERT INTO brands (tenant_id, name, description, status)
|
||||
VALUES (sqlc.arg(tenant_id), sqlc.arg(name), sqlc.arg(description), 'active')
|
||||
RETURNING id, created_at;
|
||||
INSERT INTO brands (tenant_id, name, description, status, sort_order)
|
||||
VALUES (sqlc.arg(tenant_id), sqlc.arg(name), sqlc.arg(description), 'active', sqlc.arg(sort_order))
|
||||
RETURNING id, sort_order, created_at;
|
||||
|
||||
-- name: UpdateBrand :exec
|
||||
UPDATE brands SET name = sqlc.arg(name), description = sqlc.arg(description), updated_at = NOW()
|
||||
|
||||
@@ -50,6 +50,20 @@ func (h *BrandHandler) Create(c *gin.Context) {
|
||||
response.SuccessWithStatus(c, 201, data)
|
||||
}
|
||||
|
||||
func (h *BrandHandler) Reorder(c *gin.Context) {
|
||||
var req app.BrandReorderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.Reorder(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *BrandHandler) Detail(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
|
||||
@@ -235,6 +235,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
brands.GET("", brandHandler.List)
|
||||
brands.GET("/library-summary", brandHandler.Summary)
|
||||
brands.POST("", brandHandler.Create)
|
||||
brands.PUT("/order", brandHandler.Reorder)
|
||||
brands.GET("/:id", brandHandler.Detail)
|
||||
brands.PUT("/:id", brandHandler.Update)
|
||||
brands.DELETE("/:id", brandHandler.Delete)
|
||||
|
||||
@@ -43,6 +43,7 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) {
|
||||
{http.MethodGet, "/api/tenant/articles"},
|
||||
{http.MethodGet, "/api/tenant/brands"},
|
||||
{http.MethodGet, "/api/tenant/brands/library-summary"},
|
||||
{http.MethodPut, "/api/tenant/brands/order"},
|
||||
{http.MethodGet, "/api/tenant/monitoring/brands/:brand_id/questions/:question_id/detail"},
|
||||
{http.MethodGet, "/api/tenant/monitoring/marked-articles"},
|
||||
{http.MethodPost, "/api/tenant/monitoring/marked-articles"},
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS idx_brands_tenant_sort_active;
|
||||
|
||||
ALTER TABLE brands
|
||||
DROP COLUMN IF EXISTS sort_order;
|
||||
@@ -0,0 +1,24 @@
|
||||
ALTER TABLE brands
|
||||
ADD COLUMN sort_order INT;
|
||||
|
||||
WITH ordered_brands AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY tenant_id
|
||||
ORDER BY created_at DESC, id DESC
|
||||
)::INT * 1000 AS next_sort_order
|
||||
FROM brands
|
||||
)
|
||||
UPDATE brands
|
||||
SET sort_order = ordered_brands.next_sort_order
|
||||
FROM ordered_brands
|
||||
WHERE brands.id = ordered_brands.id;
|
||||
|
||||
ALTER TABLE brands
|
||||
ALTER COLUMN sort_order SET DEFAULT 1000,
|
||||
ALTER COLUMN sort_order SET NOT NULL;
|
||||
|
||||
CREATE INDEX idx_brands_tenant_sort_active
|
||||
ON brands(tenant_id, sort_order ASC, created_at DESC, id DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
Reference in New Issue
Block a user