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