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