feat(tenant): per-tenant brand and question limit overrides

Add nullable brand_limit / question_limit columns on tenants so ops
admins can override the plan-derived brand-library quotas per account.
When set, these take precedence over config.yml plan defaults across the
brand-library summary, question materialization, and the daily
monitoring worker's primary-client plan resolution.

Ops side exposes them on admin-user create plus a new
PUT /admin-users/:id/limits endpoint, invalidating the brand-library
summary cache on change. The ops-web AdminUsers view gains matching
inputs on the create and edit modals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-24 09:04:06 +08:00
parent d69510274c
commit 348f2e3451
16 changed files with 413 additions and 63 deletions
+57 -20
View File
@@ -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
@@ -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)
})
}
}
@@ -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
),
@@ -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")
}
@@ -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
}
@@ -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 {