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);