diff --git a/apps/ops-web/src/views/AdminUsersView.vue b/apps/ops-web/src/views/AdminUsersView.vue
index 9476d1f..2042429 100644
--- a/apps/ops-web/src/views/AdminUsersView.vue
+++ b/apps/ops-web/src/views/AdminUsersView.vue
@@ -201,6 +201,24 @@
+
+
+
+
+
+
@@ -240,6 +258,24 @@
:allow-clear="false"
/>
+
+
+
+
+
+
@@ -320,6 +356,8 @@ interface AdminUserRow {
plan_name: string | null
subscription_status: string | null
subscription_end_at: string | null
+ brand_limit: number | null
+ question_limit: number | null
kol_profile_id: number | null
kol_display_name: string | null
kol_status: string | null
@@ -481,6 +519,8 @@ const createForm = reactive({
password: '',
role: 'tenant_admin',
plan_code: '',
+ brand_limit: null as number | null,
+ question_limit: null as number | null,
})
function resetCreate() {
@@ -490,6 +530,8 @@ function resetCreate() {
createForm.password = ''
createForm.role = 'tenant_admin'
createForm.plan_code = defaultPlanCode()
+ createForm.brand_limit = null
+ createForm.question_limit = null
createOpen.value = false
}
@@ -514,13 +556,15 @@ async function submitCreate() {
}
createLoading.value = true
try {
- await http.post('/admin-users', {
+ await http.post('/admin-users', {
phone,
name: createForm.name.trim(),
email: createForm.email.trim(),
password: createForm.password,
role: createForm.role,
plan_code: createForm.plan_code,
+ brand_limit: createForm.brand_limit,
+ question_limit: createForm.question_limit,
})
message.success('新建成功')
resetCreate()
@@ -542,6 +586,8 @@ const editForm = reactive({
role: 'tenant_admin',
plan_code: '',
subscription_end_date: '',
+ brand_limit: null as number | null,
+ question_limit: null as number | null,
})
function openEdit(row: AdminUserRow) {
@@ -551,6 +597,8 @@ function openEdit(row: AdminUserRow) {
editForm.email = row.email ?? ''
editForm.role = row.tenant_role ?? 'tenant_admin'
editForm.plan_code = row.plan_code ?? defaultPlanCode()
+ editForm.brand_limit = row.brand_limit
+ editForm.question_limit = row.question_limit
editForm.subscription_end_date = row.subscription_end_at
? dayjs(row.subscription_end_at).format('YYYY-MM-DD')
: estimatePlanEndDate(editForm.plan_code)
@@ -604,6 +652,10 @@ async function submitEdit() {
end_at: subscriptionEndAt,
})
}
+ await http.put(`/admin-users/${editTarget.value.id}/limits`, {
+ brand_limit: editForm.brand_limit,
+ question_limit: editForm.question_limit,
+ })
message.success('用户设置已更新')
editOpen.value = false
void reload()
@@ -818,6 +870,10 @@ onMounted(() => {
width: 100%;
}
+.admin-users-limit-input {
+ width: 100%;
+}
+
.admin-users-toolbar__spacer {
flex: 1;
}
diff --git a/server/internal/ops/app/admin_user.go b/server/internal/ops/app/admin_user.go
index b574b4e..5397518 100644
--- a/server/internal/ops/app/admin_user.go
+++ b/server/internal/ops/app/admin_user.go
@@ -23,6 +23,7 @@ import (
const (
ActionAdminUserCreate = "admin_user.create"
ActionAdminUserUpdateProfile = "admin_user.update_profile"
+ ActionAdminUserUpdateLimits = "admin_user.update_limits"
ActionAdminUserChangePlan = "admin_user.change_plan"
ActionAdminUserChangeRole = "admin_user.change_role"
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
@@ -101,6 +102,8 @@ type AdminUserView struct {
TenantID *int64 `json:"tenant_id"`
TenantName *string `json:"tenant_name"`
TenantStatus *string `json:"tenant_status"`
+ BrandLimit *int `json:"brand_limit"`
+ QuestionLimit *int `json:"question_limit"`
TenantRole *string `json:"tenant_role"`
PrimaryWorkspaceID *int64 `json:"primary_workspace_id"`
PlanCode *string `json:"plan_code"`
@@ -148,12 +151,14 @@ type AdminUserListResult struct {
}
type CreateAdminUserInput struct {
- Phone string
- Email string
- Password string
- Name string
- PlanCode string
- Role string
+ Phone string
+ Email string
+ Password string
+ Name string
+ PlanCode string
+ Role string
+ BrandLimit *int
+ QuestionLimit *int
}
type UpdateAdminUserInput struct {
@@ -162,6 +167,11 @@ type UpdateAdminUserInput struct {
Name string
}
+type UpdateAdminUserLimitsInput struct {
+ BrandLimit *int
+ QuestionLimit *int
+}
+
type SetAdminUserKOLInput struct {
Enabled bool
DisplayName string
@@ -205,6 +215,8 @@ func toAdminUserView(u *repository.AdminUserRecord) AdminUserView {
TenantID: u.TenantID,
TenantName: u.TenantName,
TenantStatus: u.TenantStatus,
+ BrandLimit: u.BrandLimit,
+ QuestionLimit: u.QuestionLimit,
TenantRole: u.TenantRole,
PrimaryWorkspaceID: u.PrimaryWorkspaceID,
PlanCode: u.PlanCode,
@@ -332,6 +344,12 @@ func (s *AdminUserService) Get(ctx context.Context, id int64) (*AdminUserView, e
}
func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAdminUserInput) (*AdminUserView, error) {
+ if err := validateOptionalAdminUserLimit("brand_limit", in.BrandLimit); err != nil {
+ return nil, err
+ }
+ if err := validateOptionalAdminUserLimit("question_limit", in.QuestionLimit); err != nil {
+ return nil, err
+ }
phone, err := normalizePhone(in.Phone)
if err != nil {
return nil, err
@@ -370,6 +388,8 @@ func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAd
PlanCode: planCode,
TenantRole: role,
WorkspaceRole: workspaceRoleForTenantRole(role),
+ BrandLimit: in.BrandLimit,
+ QuestionLimit: in.QuestionLimit,
})
if err != nil {
return nil, s.mapWriteError(err)
@@ -377,12 +397,14 @@ func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAd
if actor != nil {
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserCreate, "admin_user", user.ID, map[string]any{
- "phone": phone,
- "email": email,
- "name": name,
- "plan_code": planCode,
- "tenant_role": role,
- "tenant_id": user.TenantID,
+ "phone": phone,
+ "email": email,
+ "name": name,
+ "plan_code": planCode,
+ "tenant_role": role,
+ "tenant_id": user.TenantID,
+ "brand_limit": in.BrandLimit,
+ "question_limit": in.QuestionLimit,
}))
}
@@ -422,6 +444,44 @@ func (s *AdminUserService) Update(ctx context.Context, actor *Actor, id int64, i
return nil
}
+func (s *AdminUserService) UpdateLimits(ctx context.Context, actor *Actor, id int64, in UpdateAdminUserLimitsInput) (*AdminUserView, error) {
+ if err := validateOptionalAdminUserLimit("brand_limit", in.BrandLimit); err != nil {
+ return nil, err
+ }
+ if err := validateOptionalAdminUserLimit("question_limit", in.QuestionLimit); err != nil {
+ return nil, err
+ }
+
+ user, err := s.users.UpdateLimits(ctx, id, in.BrandLimit, in.QuestionLimit)
+ if err != nil {
+ if errors.Is(err, repository.ErrAdminUserNotFound) {
+ return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
+ }
+ return nil, response.ErrInternal(50042, "update_failed", "failed to update user limits")
+ }
+ if user.TenantID != nil {
+ s.invalidateQuotaSummaryCache(ctx, *user.TenantID)
+ }
+
+ if actor != nil {
+ _ = s.audits.Append(ctx, actor.audit(ActionAdminUserUpdateLimits, "admin_user", id, map[string]any{
+ "tenant_id": user.TenantID,
+ "brand_limit": in.BrandLimit,
+ "question_limit": in.QuestionLimit,
+ }))
+ }
+
+ view := toAdminUserView(user)
+ return &view, nil
+}
+
+func validateOptionalAdminUserLimit(field string, value *int) error {
+ if value != nil && *value <= 0 {
+ return response.ErrBadRequest(40041, "invalid_limit", field+" must be a positive integer or null")
+ }
+ return nil
+}
+
func (s *AdminUserService) ChangePlan(ctx context.Context, actor *Actor, id int64, planCode string) (*AdminUserView, error) {
normalizedPlanCode := strings.TrimSpace(planCode)
if normalizedPlanCode == "" {
@@ -529,6 +589,7 @@ func (s *AdminUserService) invalidateQuotaSummaryCache(ctx context.Context, tena
return
}
_ = s.cache.Delete(ctx, fmt.Sprintf("workspace:quota_summary:%d", tenantID))
+ _ = s.cache.Delete(ctx, fmt.Sprintf("brand:library_summary:%d", tenantID))
}
func (s *AdminUserService) ChangeRole(ctx context.Context, actor *Actor, id int64, role string) (*AdminUserView, error) {
diff --git a/server/internal/ops/app/admin_user_cache_test.go b/server/internal/ops/app/admin_user_cache_test.go
index b405018..c930334 100644
--- a/server/internal/ops/app/admin_user_cache_test.go
+++ b/server/internal/ops/app/admin_user_cache_test.go
@@ -12,7 +12,7 @@ func TestAdminUserInvalidateQuotaSummaryCache(t *testing.T) {
svc.invalidateQuotaSummaryCache(context.Background(), 42)
- if want := []string{"workspace:quota_summary:42"}; !reflect.DeepEqual(cache.deletedKeys, want) {
+ if want := []string{"workspace:quota_summary:42", "brand:library_summary:42"}; !reflect.DeepEqual(cache.deletedKeys, want) {
t.Fatalf("deleted keys = %v, want %v", cache.deletedKeys, want)
}
}
diff --git a/server/internal/ops/app/admin_user_limits_test.go b/server/internal/ops/app/admin_user_limits_test.go
new file mode 100644
index 0000000..7a226be
--- /dev/null
+++ b/server/internal/ops/app/admin_user_limits_test.go
@@ -0,0 +1,39 @@
+package app
+
+import (
+ "testing"
+
+ "github.com/geo-platform/tenant-api/internal/shared/response"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateOptionalAdminUserLimit(t *testing.T) {
+ positive := 3
+ zero := 0
+ negative := -1
+
+ tests := []struct {
+ name string
+ value *int
+ wantErr bool
+ }{
+ {name: "null inherits config", value: nil},
+ {name: "positive override", value: &positive},
+ {name: "zero rejected", value: &zero, wantErr: true},
+ {name: "negative rejected", value: &negative, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateOptionalAdminUserLimit("brand_limit", tt.value)
+ if !tt.wantErr {
+ require.NoError(t, err)
+ return
+ }
+ var appErr *response.AppError
+ require.ErrorAs(t, err, &appErr)
+ assert.Equal(t, "invalid_limit", appErr.Message)
+ })
+ }
+}
diff --git a/server/internal/ops/repository/admin_user.go b/server/internal/ops/repository/admin_user.go
index 179f009..8048c20 100644
--- a/server/internal/ops/repository/admin_user.go
+++ b/server/internal/ops/repository/admin_user.go
@@ -36,6 +36,8 @@ type AdminUserRecord struct {
TenantID *int64
TenantName *string
TenantStatus *string
+ BrandLimit *int
+ QuestionLimit *int
TenantRole *string
PrimaryWorkspaceID *int64
PlanCode *string
@@ -76,6 +78,8 @@ type CreateAdminUserInput struct {
PlanCode string
TenantRole string
WorkspaceRole string
+ BrandLimit *int
+ QuestionLimit *int
}
type UpdateAdminUserProfileInput struct {
@@ -106,6 +110,8 @@ u.status,
tm.tenant_id,
t.name AS tenant_name,
t.status AS tenant_status,
+t.brand_limit,
+t.question_limit,
tm.tenant_role,
wm.workspace_id AS primary_workspace_id,
sub.plan_code,
@@ -186,6 +192,8 @@ func scanAdminUser(row pgx.Row) (*AdminUserRecord, error) {
&u.TenantID,
&u.TenantName,
&u.TenantStatus,
+ &u.BrandLimit,
+ &u.QuestionLimit,
&u.TenantRole,
&u.PrimaryWorkspaceID,
&u.PlanCode,
@@ -333,9 +341,9 @@ RETURNING id`, in.Email, in.Phone, in.PasswordHash, in.Name).Scan(&userID); err
var tenantID int64
tenantName := fmt.Sprintf("user-%d", userID)
if err := tx.QueryRow(ctx, `
-INSERT INTO tenants (name, status)
-VALUES ($1, 'active')
-RETURNING id`, tenantName).Scan(&tenantID); err != nil {
+INSERT INTO tenants (name, status, brand_limit, question_limit)
+VALUES ($1, 'active', $2, $3)
+RETURNING id`, tenantName, in.BrandLimit, in.QuestionLimit).Scan(&tenantID); err != nil {
return nil, err
}
@@ -782,6 +790,36 @@ WHERE id = $4
return nil
}
+func (r *AdminUserRepository) UpdateLimits(ctx context.Context, userID int64, brandLimit, questionLimit *int) (*AdminUserRecord, error) {
+ var tenantID int64
+ err := r.pool.QueryRow(ctx, `
+WITH target_tenant AS (
+ SELECT tm.tenant_id
+ FROM tenant_memberships tm
+ JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL
+ WHERE tm.user_id = $1
+ AND tm.deleted_at IS NULL
+ ORDER BY tm.created_at ASC, tm.id ASC
+ LIMIT 1
+)
+UPDATE tenants t
+SET brand_limit = $2,
+ question_limit = $3,
+ updated_at = NOW()
+FROM target_tenant target
+WHERE t.id = target.tenant_id
+ AND t.deleted_at IS NULL
+RETURNING t.id`, userID, brandLimit, questionLimit).Scan(&tenantID)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrAdminUserNotFound
+ }
+ return nil, err
+ }
+
+ return r.GetByID(ctx, userID)
+}
+
func (r *AdminUserRepository) UpdateStatus(ctx context.Context, id int64, status string) error {
cmd, err := r.pool.Exec(ctx, `
UPDATE users
diff --git a/server/internal/ops/transport/admin_user_handler.go b/server/internal/ops/transport/admin_user_handler.go
index 988640a..11d09bc 100644
--- a/server/internal/ops/transport/admin_user_handler.go
+++ b/server/internal/ops/transport/admin_user_handler.go
@@ -11,12 +11,14 @@ import (
)
type createAdminUserRequest struct {
- Phone string `json:"phone" binding:"required"`
- Email string `json:"email"`
- Name string `json:"name"`
- Password string `json:"password" binding:"required"`
- PlanCode string `json:"plan_code"`
- Role string `json:"role"`
+ Phone string `json:"phone" binding:"required"`
+ Email string `json:"email"`
+ Name string `json:"name"`
+ Password string `json:"password" binding:"required"`
+ PlanCode string `json:"plan_code"`
+ Role string `json:"role"`
+ BrandLimit *int `json:"brand_limit"`
+ QuestionLimit *int `json:"question_limit"`
}
type updateAdminUserRequest struct {
@@ -25,6 +27,11 @@ type updateAdminUserRequest struct {
Name string `json:"name"`
}
+type updateAdminUserLimitsRequest struct {
+ BrandLimit *int `json:"brand_limit"`
+ QuestionLimit *int `json:"question_limit"`
+}
+
type changeAdminUserPlanRequest struct {
PlanCode string `json:"plan_code" binding:"required"`
}
@@ -86,12 +93,14 @@ func createAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc {
return
}
detail, err := svc.Create(c.Request.Context(), actorFromGin(c), app.CreateAdminUserInput{
- Phone: body.Phone,
- Email: body.Email,
- Name: body.Name,
- Password: body.Password,
- PlanCode: body.PlanCode,
- Role: body.Role,
+ Phone: body.Phone,
+ Email: body.Email,
+ Name: body.Name,
+ Password: body.Password,
+ PlanCode: body.PlanCode,
+ Role: body.Role,
+ BrandLimit: body.BrandLimit,
+ QuestionLimit: body.QuestionLimit,
})
if err != nil {
response.Error(c, err)
@@ -141,6 +150,30 @@ func updateAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc {
}
}
+func updateAdminUserLimitsHandler(svc *app.AdminUserService) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ id, err := parseIDParam(c)
+ if err != nil {
+ response.Error(c, err)
+ return
+ }
+ var body updateAdminUserLimitsRequest
+ if err := c.ShouldBindJSON(&body); err != nil {
+ response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
+ return
+ }
+ detail, err := svc.UpdateLimits(c.Request.Context(), actorFromGin(c), id, app.UpdateAdminUserLimitsInput{
+ BrandLimit: body.BrandLimit,
+ QuestionLimit: body.QuestionLimit,
+ })
+ if err != nil {
+ response.Error(c, err)
+ return
+ }
+ response.Success(c, detail)
+ }
+}
+
func changeAdminUserPlanHandler(svc *app.AdminUserService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
diff --git a/server/internal/ops/transport/router.go b/server/internal/ops/transport/router.go
index aa9614d..122f0cf 100644
--- a/server/internal/ops/transport/router.go
+++ b/server/internal/ops/transport/router.go
@@ -112,6 +112,7 @@ func RegisterRoutes(d Deps) {
authed.POST("/admin-users", createAdminUserHandler(d.AdminUsers))
authed.GET("/admin-users/:id", getAdminUserHandler(d.AdminUsers))
authed.PATCH("/admin-users/:id", updateAdminUserHandler(d.AdminUsers))
+ authed.PUT("/admin-users/:id/limits", updateAdminUserLimitsHandler(d.AdminUsers))
authed.POST("/admin-users/:id/plan", changeAdminUserPlanHandler(d.AdminUsers))
authed.POST("/admin-users/:id/subscription-expiry", updateAdminUserSubscriptionExpiryHandler(d.AdminUsers))
authed.POST("/admin-users/:id/reset-plan-usage", resetAdminUserPlanUsageHandler(d.AdminUsers))
diff --git a/server/internal/shared/swagger/descriptions.go b/server/internal/shared/swagger/descriptions.go
index f38a7b3..bc7b903 100644
--- a/server/internal/shared/swagger/descriptions.go
+++ b/server/internal/shared/swagger/descriptions.go
@@ -38,6 +38,7 @@ var routeDocs = map[string]routeDoc{
"POST /api/ops/admin-users": {"新建租户管理员", "创建租户管理员账号。"},
"GET /api/ops/admin-users/:id": {"租户管理员详情", "读取租户管理员账号详情。"},
"PATCH /api/ops/admin-users/:id": {"更新租户管理员", "更新租户管理员基础资料。"},
+ "PUT /api/ops/admin-users/:id/limits": {"调整品牌库限额", "设置租户管理员账号的品牌和问题数量覆盖限额。"},
"POST /api/ops/admin-users/:id/plan": {"调整租户套餐", "调整租户管理员账号绑定的套餐。"},
"POST /api/ops/admin-users/:id/subscription-expiry": {"调整订阅到期时间", "更新租户管理员账号订阅到期时间。"},
"POST /api/ops/admin-users/:id/reset-plan-usage": {"重置套餐用量", "重置租户管理员账号套餐用量。"},
diff --git a/server/internal/tenant/app/brand_service.go b/server/internal/tenant/app/brand_service.go
index c302429..38b42c6 100644
--- a/server/internal/tenant/app/brand_service.go
+++ b/server/internal/tenant/app/brand_service.go
@@ -1154,8 +1154,15 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand
}
type brandLibraryPlan struct {
- PlanCode string
- PlanName string
+ PlanCode string
+ PlanName string
+ BrandLimit *int
+ QuestionLimit *int
+}
+
+type brandLibraryEffectiveLimits struct {
+ MaxBrands int
+ MaxQuestions int
}
type brandLibraryUsage struct {
@@ -1176,27 +1183,47 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
}
limits := s.currentLimits()
- maxBrands := limits.BrandLimitForPlan(plan.PlanCode)
+ effectiveLimits := resolveBrandLibraryLimits(limits, plan)
maxKeywords := limits.MaxKeywords
- maxQuestions := limits.QuestionLimitForPlan(plan.PlanCode)
return &BrandLibrarySummaryResponse{
PlanCode: plan.PlanCode,
PlanName: plan.PlanName,
- MaxBrands: maxBrands,
+ MaxBrands: effectiveLimits.MaxBrands,
UsedBrands: usage.BrandCount,
- RemainingBrands: maxInt(maxBrands-usage.BrandCount, 0),
+ RemainingBrands: maxInt(effectiveLimits.MaxBrands-usage.BrandCount, 0),
MaxKeywords: maxKeywords,
UsedKeywords: usage.KeywordCount,
RemainingKeywords: maxInt(maxKeywords-usage.KeywordCount, 0),
- MaxQuestions: maxQuestions,
+ MaxQuestions: effectiveLimits.MaxQuestions,
UsedQuestions: usage.QuestionCount,
- RemainingQuestions: maxInt(maxQuestions-usage.QuestionCount, 0),
+ RemainingQuestions: maxInt(effectiveLimits.MaxQuestions-usage.QuestionCount, 0),
MaxQuestionsPerKeyword: limits.MaxQuestionsPerKeyword,
- MaxQuestionsPerBrand: maxQuestions,
+ MaxQuestionsPerBrand: effectiveLimits.MaxQuestions,
}, nil
}
+func resolveBrandLibraryLimits(limits sharedconfig.BrandLibraryConfig, plan *brandLibraryPlan) brandLibraryEffectiveLimits {
+ planCode := "free"
+ if plan != nil {
+ planCode = plan.PlanCode
+ }
+ effective := brandLibraryEffectiveLimits{
+ MaxBrands: limits.BrandLimitForPlan(planCode),
+ MaxQuestions: limits.QuestionLimitForPlan(planCode),
+ }
+ if plan == nil {
+ return effective
+ }
+ if plan.BrandLimit != nil && *plan.BrandLimit > 0 {
+ effective.MaxBrands = *plan.BrandLimit
+ }
+ if plan.QuestionLimit != nil && *plan.QuestionLimit > 0 {
+ effective.MaxQuestions = *plan.QuestionLimit
+ }
+ return effective
+}
+
func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) {
plan := &brandLibraryPlan{
PlanCode: "free",
@@ -1204,17 +1231,27 @@ func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64)
}
err := s.pool.QueryRow(ctx, `
- SELECT p.plan_code, p.name
- FROM tenant_plan_subscriptions s
- JOIN plans p ON p.id = s.plan_id
- WHERE s.tenant_id = $1
- AND s.status = 'active'
- AND s.deleted_at IS NULL
- AND p.status = 'active'
- AND s.end_at > $2
- ORDER BY s.start_at DESC
- LIMIT 1
- `, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName)
+ SELECT
+ COALESCE(active_plan.plan_code, 'free'),
+ COALESCE(active_plan.name, ''),
+ t.brand_limit,
+ t.question_limit
+ FROM tenants t
+ LEFT JOIN LATERAL (
+ SELECT p.plan_code, p.name
+ FROM tenant_plan_subscriptions s
+ JOIN plans p ON p.id = s.plan_id
+ WHERE s.tenant_id = t.id
+ AND s.status = 'active'
+ AND s.deleted_at IS NULL
+ AND p.status = 'active'
+ AND s.end_at > $2
+ ORDER BY s.start_at DESC
+ LIMIT 1
+ ) active_plan ON TRUE
+ WHERE t.id = $1
+ AND t.deleted_at IS NULL
+ `, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName, &plan.BrandLimit, &plan.QuestionLimit)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return plan, nil
diff --git a/server/internal/tenant/app/brand_service_limits_test.go b/server/internal/tenant/app/brand_service_limits_test.go
new file mode 100644
index 0000000..ff0bbca
--- /dev/null
+++ b/server/internal/tenant/app/brand_service_limits_test.go
@@ -0,0 +1,62 @@
+package app
+
+import (
+ "testing"
+
+ sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestResolveBrandLibraryLimits(t *testing.T) {
+ limits := sharedconfig.BrandLibraryConfig{
+ FreeBrandLimit: 1,
+ PaidBrandLimit: 2,
+ QuestionLimitsByPlan: map[string]int{"default": 25, "free": 5, "pro": 50},
+ }
+ brandLimit := 7
+ questionLimit := 80
+
+ tests := []struct {
+ name string
+ plan *brandLibraryPlan
+ wantBrands int
+ wantQuestion int
+ }{
+ {
+ name: "config defaults for free plan",
+ plan: &brandLibraryPlan{PlanCode: "free"},
+ wantBrands: 1,
+ wantQuestion: 5,
+ },
+ {
+ name: "config defaults for paid plan",
+ plan: &brandLibraryPlan{PlanCode: "pro"},
+ wantBrands: 2,
+ wantQuestion: 50,
+ },
+ {
+ name: "tenant overrides take priority",
+ plan: &brandLibraryPlan{
+ PlanCode: "free",
+ BrandLimit: &brandLimit,
+ QuestionLimit: &questionLimit,
+ },
+ wantBrands: 7,
+ wantQuestion: 80,
+ },
+ {
+ name: "missing plan falls back to free config",
+ plan: nil,
+ wantBrands: 1,
+ wantQuestion: 5,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := resolveBrandLibraryLimits(limits, tt.plan)
+ assert.Equal(t, tt.wantBrands, got.MaxBrands)
+ assert.Equal(t, tt.wantQuestion, got.MaxQuestions)
+ })
+ }
+}
diff --git a/server/internal/tenant/app/monitoring_daily_task_worker.go b/server/internal/tenant/app/monitoring_daily_task_worker.go
index cd1cb83..1657db2 100644
--- a/server/internal/tenant/app/monitoring_daily_task_worker.go
+++ b/server/internal/tenant/app/monitoring_daily_task_worker.go
@@ -666,8 +666,12 @@ func dailyMonitoringPrimaryClientPlansSQL() string {
dc.id AS primary_client_id,
dc.created_at,
p.plan_code,
- p.quota_policy_json
+ p.quota_policy_json,
+ t.brand_limit
FROM desktop_clients dc
+ JOIN tenants t
+ ON t.id = dc.tenant_id
+ AND t.deleted_at IS NULL
JOIN tenant_plan_subscriptions s
ON s.tenant_id = dc.tenant_id
AND s.status = 'active'
@@ -697,7 +701,8 @@ func dailyMonitoringPrimaryClientPlansSQL() string {
ec.workspace_id,
ec.primary_client_id,
ec.plan_code,
- ec.quota_policy_json
+ ec.quota_policy_json,
+ ec.brand_limit
FROM eligible_clients ec
LEFT JOIN desktop_client_primary_leases l
ON l.tenant_id = ec.tenant_id
@@ -717,6 +722,7 @@ func dailyMonitoringPrimaryClientPlansSQL() string {
c.primary_client_id,
GREATEST(
COALESCE(
+ c.brand_limit,
NULLIF((c.quota_policy_json ->> 'brand_limit')::int, 0),
CASE WHEN c.plan_code = 'free' THEN $1::int ELSE $2::int END
),
diff --git a/server/internal/tenant/app/monitoring_daily_task_worker_test.go b/server/internal/tenant/app/monitoring_daily_task_worker_test.go
index 4eadf10..827cec7 100644
--- a/server/internal/tenant/app/monitoring_daily_task_worker_test.go
+++ b/server/internal/tenant/app/monitoring_daily_task_worker_test.go
@@ -507,9 +507,11 @@ func TestBuildDailyMonitoringPlansFromSelectedClientsRequiresPlatformSnapshot(t
func TestDailyMonitoringPrimaryClientPlansSQLUsesContiguousParameters(t *testing.T) {
sql := dailyMonitoringPrimaryClientPlansSQL()
+ normalized := normalizeSQLWhitespace(sql)
- assert.Contains(t, normalizeSQLWhitespace(sql), "pa.platform_id = ANY($3::text[])")
- assert.Contains(t, normalizeSQLWhitespace(sql), "dc.last_seen_at >= NOW() - $4::interval")
+ assert.Contains(t, normalized, "pa.platform_id = ANY($3::text[])")
+ assert.Contains(t, normalized, "dc.last_seen_at >= NOW() - $4::interval")
+ assert.Contains(t, normalized, "COALESCE( c.brand_limit, NULLIF((c.quota_policy_json ->> 'brand_limit')::int, 0)")
assert.NotContains(t, sql, "$5")
}
diff --git a/server/internal/tenant/app/question_expansion_service.go b/server/internal/tenant/app/question_expansion_service.go
index 08e04c6..4c63aa6 100644
--- a/server/internal/tenant/app/question_expansion_service.go
+++ b/server/internal/tenant/app/question_expansion_service.go
@@ -133,7 +133,7 @@ type questionBrandContext struct {
BrandName string
Website *string
Description *string
- PlanCode string
+ QuestionLimit int
CompetitorNames []string
}
@@ -473,7 +473,7 @@ func (s *QuestionExpansionService) MaterializeQuestions(ctx context.Context, bra
return nil, err
}
- maxQuestions := s.brand.currentLimits().QuestionLimitForPlan(brandCtx.PlanCode)
+ maxQuestions := brandCtx.QuestionLimit
remaining := maxQuestions - currentCount
if remaining < 0 {
remaining = 0
@@ -663,7 +663,7 @@ func (s *QuestionExpansionService) loadQuestionBrandContext(ctx context.Context,
BrandName: brandName,
Website: website,
Description: description,
- PlanCode: plan.PlanCode,
+ QuestionLimit: resolveBrandLibraryLimits(s.brand.currentLimits(), plan).MaxQuestions,
CompetitorNames: competitors,
}, nil
}
diff --git a/server/internal/tenant/repository/generated/models.go b/server/internal/tenant/repository/generated/models.go
index 9477e34..c1846bf 100644
--- a/server/internal/tenant/repository/generated/models.go
+++ b/server/internal/tenant/repository/generated/models.go
@@ -1053,13 +1053,15 @@ type TemplateAssistTask struct {
}
type Tenant struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- Status string `json:"status"`
- FrozenAt pgtype.Timestamptz `json:"frozen_at"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ FrozenAt pgtype.Timestamptz `json:"frozen_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+ BrandLimit pgtype.Int4 `json:"brand_limit"`
+ QuestionLimit pgtype.Int4 `json:"question_limit"`
}
type TenantImageStorageUsage struct {
diff --git a/server/migrations/20260723100000_add_tenant_brand_question_limits.down.sql b/server/migrations/20260723100000_add_tenant_brand_question_limits.down.sql
new file mode 100644
index 0000000..4481d64
--- /dev/null
+++ b/server/migrations/20260723100000_add_tenant_brand_question_limits.down.sql
@@ -0,0 +1,5 @@
+ALTER TABLE tenants
+DROP CONSTRAINT IF EXISTS chk_tenants_question_limit_positive,
+DROP CONSTRAINT IF EXISTS chk_tenants_brand_limit_positive,
+DROP COLUMN IF EXISTS question_limit,
+DROP COLUMN IF EXISTS brand_limit;
diff --git a/server/migrations/20260723100000_add_tenant_brand_question_limits.up.sql b/server/migrations/20260723100000_add_tenant_brand_question_limits.up.sql
new file mode 100644
index 0000000..3ebc014
--- /dev/null
+++ b/server/migrations/20260723100000_add_tenant_brand_question_limits.up.sql
@@ -0,0 +1,7 @@
+ALTER TABLE tenants
+ADD COLUMN brand_limit INT,
+ADD COLUMN question_limit INT,
+ADD CONSTRAINT chk_tenants_brand_limit_positive
+ CHECK (brand_limit IS NULL OR brand_limit > 0),
+ADD CONSTRAINT chk_tenants_question_limit_positive
+ CHECK (question_limit IS NULL OR question_limit > 0);