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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user