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
+41 -3
View File
@@ -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