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
+10
View File
@@ -1494,10 +1494,20 @@ const enUS = {
competitorLines: 'Product lines',
competitorLinesHint: 'Separate multiple product lines with commas.',
},
order: {
eyebrow: 'Display Order',
title: 'Company display order',
adjust: 'Adjust order',
done: 'Done',
dragHandle: 'Drag to reorder',
moveUp: 'Move up',
moveDown: 'Move down',
},
messages: {
createBrand: 'Brand created.',
updateBrand: 'Brand updated.',
deleteBrand: 'Brand deleted.',
reorderBrand: 'Company display order updated.',
createKeyword: 'Keyword created.',
updateKeyword: 'Keyword updated.',
deleteKeyword: 'Keyword deleted.',
+10
View File
@@ -1430,10 +1430,20 @@ const zhCN = {
competitorLines: '重点产品线',
competitorLinesHint: '使用英文逗号分隔多个产品线',
},
order: {
eyebrow: '排序',
title: '公司展示顺序',
adjust: '调整排序',
done: '完成',
dragHandle: '拖动排序',
moveUp: '上移',
moveDown: '下移',
},
messages: {
createBrand: '品牌已创建',
updateBrand: '品牌已更新',
deleteBrand: '品牌已删除',
reorderBrand: '公司展示顺序已更新',
createKeyword: '关键词已创建',
updateKeyword: '关键词已更新',
deleteKeyword: '关键词已删除',
+4
View File
@@ -10,6 +10,7 @@ import type {
AuthTokens,
Brand,
BrandLibrarySummary,
BrandReorderRequest,
BrandRequest,
ChangePasswordRequest,
ClassifiedQuestion,
@@ -1158,6 +1159,9 @@ export const brandsApi = {
remove(id: number) {
return apiClient.remove<null>(`/api/tenant/brands/${id}`)
},
reorder(payload: BrandReorderRequest) {
return apiClient.put<Brand[], BrandReorderRequest>('/api/tenant/brands/order', payload)
},
listKeywords(brandId: number) {
return apiClient.get<Keyword[]>(`/api/tenant/brands/${brandId}/keywords`)
},
+8 -2
View File
@@ -59,8 +59,7 @@ export const useCompanyStore = defineStore('company', () => {
loading.value = true
try {
const nextBrands = normalizeBrandList(await brandsApi.list())
brands.value = nextBrands
syncCurrentBrand(nextBrands)
setBrands(nextBrands)
initialized.value = true
return nextBrands
} finally {
@@ -68,6 +67,12 @@ export const useCompanyStore = defineStore('company', () => {
}
}
function setBrands(value: Brand[] | null | undefined): void {
const nextBrands = normalizeBrandList(value)
brands.value = nextBrands
syncCurrentBrand(nextBrands)
}
function reset(): void {
brands.value = []
currentBrandId.value = null
@@ -84,6 +89,7 @@ export const useCompanyStore = defineStore('company', () => {
loading,
initialized,
refreshBrands,
setBrands,
setCurrentBrand,
reset,
}
+368 -8
View File
@@ -1,7 +1,12 @@
<script setup lang="ts">
import {
ArrowDownOutlined,
ArrowUpOutlined,
CheckOutlined,
CloseOutlined,
DeleteOutlined,
EditOutlined,
HolderOutlined,
PlusOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue'
@@ -41,6 +46,10 @@ const questionEditOpen = ref(false)
const editingQuestionId = ref<number | null>(null)
const competitorModalOpen = ref(false)
const editingCompetitorId = ref<number | null>(null)
const sortMode = ref(false)
const draftBrandIds = ref<number[]>([])
const draggedBrandId = ref<number | null>(null)
const dragOverBrandId = ref<number | null>(null)
const brandForm = reactive({
name: '',
@@ -106,6 +115,25 @@ const selectedBrand = computed(
() => brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
)
const brandList = computed(() => brandListQuery.data.value ?? [])
const orderedBrands = computed(() => {
if (!sortMode.value) {
return brandList.value
}
const brandById = new Map(brandList.value.map((brand) => [brand.id, brand]))
const ordered = draftBrandIds.value
.map((brandId) => brandById.get(brandId))
.filter((brand): brand is Brand => Boolean(brand))
const orderedIds = new Set(ordered.map((brand) => brand.id))
const appended = brandList.value.filter((brand) => !orderedIds.has(brand.id))
return [...ordered, ...appended]
})
const canSortBrands = computed(() => brandList.value.length > 1)
const hasBrandOrderChanges = computed(
() => !isSameBrandOrder(draftBrandIds.value, brandList.value.map((brand) => brand.id)),
)
const currentQuestions = computed(() => questionsQuery.data.value?.items ?? [])
const questionTotal = computed(() => questionsQuery.data.value?.total ?? 0)
@@ -195,6 +223,19 @@ const brandMutations = {
}),
}
const brandOrderMutation = useMutation({
mutationFn: (brandIds: number[]) => brandsApi.reorder({ brand_ids: brandIds }),
onSuccess: async (brands) => {
message.success(t('brands.messages.reorderBrand'))
queryClient.setQueryData(['brands', 'list'], brands)
companyStore.setBrands(brands)
sortMode.value = false
resetBrandDragState()
await queryClient.invalidateQueries({ queryKey: ['brands'] })
},
onError: (error) => message.error(formatError(error)),
})
const questionMutations = {
createSingle: useMutation({
mutationFn: () =>
@@ -322,6 +363,16 @@ watch(
{ immediate: true },
)
watch(
() => brandList.value.map((brand) => brand.id).join(','),
() => {
if (!sortMode.value) {
draftBrandIds.value = currentBrandIds()
}
},
{ immediate: true },
)
watch([questionTotal, questionPageSize], ([total, pageSize]) => {
const maxPage = Math.max(1, Math.ceil(total / pageSize))
if (questionPage.value > maxPage) {
@@ -344,6 +395,110 @@ function stringifyProductLines(value: unknown): string {
return ''
}
function currentBrandIds(): number[] {
return brandList.value.map((brand) => brand.id)
}
function isSameBrandOrder(left: number[], right: number[]): boolean {
if (left.length !== right.length) {
return false
}
return left.every((brandId, index) => brandId === right[index])
}
function resetBrandDragState(): void {
draggedBrandId.value = null
dragOverBrandId.value = null
}
function startSortMode(): void {
if (!canSortBrands.value) {
return
}
draftBrandIds.value = currentBrandIds()
sortMode.value = true
resetBrandDragState()
}
function cancelSortMode(): void {
draftBrandIds.value = currentBrandIds()
sortMode.value = false
resetBrandDragState()
}
async function saveBrandOrder(): Promise<void> {
const brandIds = orderedBrands.value.map((brand) => brand.id)
if (!isSameBrandOrder(brandIds, currentBrandIds())) {
await brandOrderMutation.mutateAsync(brandIds)
return
}
cancelSortMode()
}
function moveDraftBrand(brandId: number, direction: -1 | 1): void {
const nextIds = [...draftBrandIds.value]
const currentIndex = nextIds.indexOf(brandId)
const nextIndex = currentIndex + direction
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= nextIds.length) {
return
}
const [movedId] = nextIds.splice(currentIndex, 1)
nextIds.splice(nextIndex, 0, movedId)
draftBrandIds.value = nextIds
}
function moveDraftBrandToTarget(sourceBrandId: number, targetBrandId: number): void {
if (sourceBrandId === targetBrandId) {
return
}
const nextIds = [...draftBrandIds.value]
const sourceIndex = nextIds.indexOf(sourceBrandId)
const targetIndex = nextIds.indexOf(targetBrandId)
if (sourceIndex < 0 || targetIndex < 0) {
return
}
const [movedId] = nextIds.splice(sourceIndex, 1)
nextIds.splice(targetIndex, 0, movedId)
draftBrandIds.value = nextIds
}
function handleBrandCardClick(brandId: number): void {
if (sortMode.value) {
return
}
selectBrand(brandId)
}
function handleBrandDragStart(brandId: number, event: DragEvent): void {
draggedBrandId.value = brandId
dragOverBrandId.value = null
event.dataTransfer?.setData('text/plain', String(brandId))
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
function handleBrandDragOver(brandId: number, event: DragEvent): void {
if (!sortMode.value || draggedBrandId.value === null || draggedBrandId.value === brandId) {
return
}
event.preventDefault()
dragOverBrandId.value = brandId
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
function handleBrandDrop(brandId: number, event: DragEvent): void {
event.preventDefault()
if (!sortMode.value || draggedBrandId.value === null) {
resetBrandDragState()
return
}
moveDraftBrandToTarget(draggedBrandId.value, brandId)
resetBrandDragState()
}
function openBrandModal(brand?: Brand): void {
if (!brand && brandLimitReached.value) {
message.warning(
@@ -490,23 +645,98 @@ async function invalidateBrandQueries(): Promise<void> {
</section>
<section class="brand-rail">
<div class="brand-rail__toolbar">
<div>
<p class="eyebrow">{{ t('brands.order.eyebrow') }}</p>
<h3>{{ t('brands.order.title') }}</h3>
</div>
<div class="brand-rail__actions">
<template v-if="sortMode">
<a-button :disabled="brandOrderMutation.isPending.value" @click="cancelSortMode">
<template #icon><CloseOutlined /></template>
{{ t('common.cancel') }}
</a-button>
<a-button
type="primary"
:disabled="!hasBrandOrderChanges"
:loading="brandOrderMutation.isPending.value"
@click="saveBrandOrder"
>
<template #icon><CheckOutlined /></template>
{{ t('brands.order.done') }}
</a-button>
</template>
<a-button v-else :disabled="!canSortBrands" @click="startSortMode">
<template #icon><HolderOutlined /></template>
{{ t('brands.order.adjust') }}
</a-button>
</div>
</div>
<div v-if="brandListQuery.isPending.value" class="brand-grid">
<a-skeleton v-for="item in 3" :key="item" active />
</div>
<div v-else-if="brandListQuery.data.value?.length" class="brand-grid">
<article
v-for="brand in brandListQuery.data.value"
v-for="(brand, index) in orderedBrands"
:key="brand.id"
class="brand-card"
:class="{ 'brand-card--active': brand.id === selectedBrandId }"
@click="selectBrand(brand.id)"
:class="{
'brand-card--active': brand.id === selectedBrandId,
'brand-card--sorting': sortMode,
'brand-card--dragging': draggedBrandId === brand.id,
'brand-card--drop-target': dragOverBrandId === brand.id,
}"
@click="handleBrandCardClick(brand.id)"
@dragover="handleBrandDragOver(brand.id, $event)"
@drop="handleBrandDrop(brand.id, $event)"
@dragend="resetBrandDragState"
>
<div class="brand-card__top">
<div>
<h3>{{ brand.name }}</h3>
<p>{{ brand.description || t('brands.empty.brandDescription') }}</p>
<div class="brand-card__identity">
<div v-if="sortMode" class="brand-card__order">
<button
type="button"
class="brand-card__drag-handle"
draggable="true"
:aria-label="t('brands.order.dragHandle')"
@click.stop
@dragstart.stop="handleBrandDragStart(brand.id, $event)"
@dragend="resetBrandDragState"
>
<HolderOutlined />
</button>
<span>{{ index + 1 }}</span>
</div>
<div class="brand-card__copy">
<h3>{{ brand.name }}</h3>
<p>{{ brand.description || t('brands.empty.brandDescription') }}</p>
</div>
</div>
<div class="brand-card__actions">
<div v-if="sortMode" class="brand-card__sort-actions">
<a-tooltip :title="t('brands.order.moveUp')">
<a-button
type="text"
shape="circle"
size="small"
:disabled="index === 0"
@click.stop="moveDraftBrand(brand.id, -1)"
>
<ArrowUpOutlined />
</a-button>
</a-tooltip>
<a-tooltip :title="t('brands.order.moveDown')">
<a-button
type="text"
shape="circle"
size="small"
:disabled="index === orderedBrands.length - 1"
@click.stop="moveDraftBrand(brand.id, 1)"
>
<ArrowDownOutlined />
</a-button>
</a-tooltip>
</div>
<div v-else class="brand-card__actions">
<a-tooltip :title="t('common.edit')">
<a-button
type="text"
@@ -533,7 +763,12 @@ async function invalidateBrandQueries(): Promise<void> {
</span>
</div>
</article>
<button type="button" class="brand-card brand-card--add" @click="openBrandModal()">
<button
v-if="!sortMode"
type="button"
class="brand-card brand-card--add"
@click="openBrandModal()"
>
<PlusOutlined />
<span>{{ t('brands.actions.addBrand') }}</span>
</button>
@@ -808,6 +1043,28 @@ async function invalidateBrandQueries(): Promise<void> {
padding: 20px;
}
.brand-rail__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.brand-rail__toolbar h3 {
margin: 4px 0 0;
color: #111827;
font-size: 18px;
font-weight: 740;
}
.brand-rail__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.brand-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -843,6 +1100,23 @@ async function invalidateBrandQueries(): Promise<void> {
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12);
}
.brand-card--sorting {
cursor: default;
}
.brand-card--dragging {
opacity: 0.46;
transform: scale(0.985);
}
.brand-card--drop-target {
border-color: #1677ff;
background: #f0f7ff;
box-shadow:
inset 0 0 0 1px rgba(22, 119, 255, 0.18),
0 14px 28px rgba(22, 119, 255, 0.1);
}
.brand-card--add {
align-items: center;
justify-content: center;
@@ -860,6 +1134,80 @@ async function invalidateBrandQueries(): Promise<void> {
gap: 16px;
}
.brand-card__identity {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.brand-card__copy {
min-width: 0;
}
.brand-card__order {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 30px;
}
.brand-card__order span {
min-width: 24px;
height: 20px;
color: #2563eb;
font-size: 12px;
font-weight: 760;
line-height: 20px;
text-align: center;
background: #eff6ff;
border-radius: 999px;
}
.brand-card__drag-handle {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
color: #64748b;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 7px;
cursor: grab;
transition:
color 0.18s ease,
border-color 0.18s ease,
background 0.18s ease;
}
.brand-card__drag-handle :deep(.anticon) {
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
}
.brand-card__drag-handle :deep(svg) {
display: block;
}
.brand-card__drag-handle:hover {
color: #1677ff;
background: #eef6ff;
border-color: #93c5fd;
}
.brand-card__drag-handle:active {
cursor: grabbing;
}
.brand-detail__actions {
display: flex;
flex-wrap: wrap;
@@ -892,6 +1240,13 @@ async function invalidateBrandQueries(): Promise<void> {
transition: opacity 0.18s ease;
}
.brand-card__sort-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
}
.brand-card:hover .brand-card__actions,
.brand-card:focus-within .brand-card__actions {
opacity: 1;
@@ -970,11 +1325,16 @@ async function invalidateBrandQueries(): Promise<void> {
@media (max-width: 760px) {
.brand-page-head,
.brand-guide,
.brand-rail__toolbar,
.brand-detail__head {
flex-direction: column;
align-items: stretch;
}
.brand-rail__actions {
justify-content: flex-start;
}
.brand-detail__actions {
justify-content: flex-start;
}
+5
View File
@@ -1695,6 +1695,7 @@ export interface Brand {
website: string | null
description: string | null
status: string
sort_order?: number
keyword_count: number
question_count: number
competitor_count?: number
@@ -1702,6 +1703,10 @@ export interface Brand {
updated_at?: string
}
export interface BrandReorderRequest {
brand_ids: number[]
}
export interface KeywordRequest {
name: string
}
+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
+1
View File
@@ -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;