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:
@@ -201,6 +201,24 @@
|
|||||||
<a-form-item label="初始套餐" required>
|
<a-form-item label="初始套餐" required>
|
||||||
<a-select v-model:value="createForm.plan_code" :options="planOptions" />
|
<a-select v-model:value="createForm.plan_code" :options="planOptions" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="品牌数量上限">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="createForm.brand_limit"
|
||||||
|
class="admin-users-limit-input"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
placeholder="留空使用 config.yml 默认值"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="问题数量上限">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="createForm.question_limit"
|
||||||
|
class="admin-users-limit-input"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
placeholder="留空使用 config.yml 默认值"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
@@ -240,6 +258,24 @@
|
|||||||
:allow-clear="false"
|
:allow-clear="false"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="品牌数量上限">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="editForm.brand_limit"
|
||||||
|
class="admin-users-limit-input"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
placeholder="留空使用 config.yml 默认值"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="问题数量上限">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="editForm.question_limit"
|
||||||
|
class="admin-users-limit-input"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
placeholder="留空使用 config.yml 默认值"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
@@ -320,6 +356,8 @@ interface AdminUserRow {
|
|||||||
plan_name: string | null
|
plan_name: string | null
|
||||||
subscription_status: string | null
|
subscription_status: string | null
|
||||||
subscription_end_at: string | null
|
subscription_end_at: string | null
|
||||||
|
brand_limit: number | null
|
||||||
|
question_limit: number | null
|
||||||
kol_profile_id: number | null
|
kol_profile_id: number | null
|
||||||
kol_display_name: string | null
|
kol_display_name: string | null
|
||||||
kol_status: string | null
|
kol_status: string | null
|
||||||
@@ -481,6 +519,8 @@ const createForm = reactive({
|
|||||||
password: '',
|
password: '',
|
||||||
role: 'tenant_admin',
|
role: 'tenant_admin',
|
||||||
plan_code: '',
|
plan_code: '',
|
||||||
|
brand_limit: null as number | null,
|
||||||
|
question_limit: null as number | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
function resetCreate() {
|
function resetCreate() {
|
||||||
@@ -490,6 +530,8 @@ function resetCreate() {
|
|||||||
createForm.password = ''
|
createForm.password = ''
|
||||||
createForm.role = 'tenant_admin'
|
createForm.role = 'tenant_admin'
|
||||||
createForm.plan_code = defaultPlanCode()
|
createForm.plan_code = defaultPlanCode()
|
||||||
|
createForm.brand_limit = null
|
||||||
|
createForm.question_limit = null
|
||||||
createOpen.value = false
|
createOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,13 +556,15 @@ async function submitCreate() {
|
|||||||
}
|
}
|
||||||
createLoading.value = true
|
createLoading.value = true
|
||||||
try {
|
try {
|
||||||
await http.post('/admin-users', {
|
await http.post<AdminUserRow>('/admin-users', {
|
||||||
phone,
|
phone,
|
||||||
name: createForm.name.trim(),
|
name: createForm.name.trim(),
|
||||||
email: createForm.email.trim(),
|
email: createForm.email.trim(),
|
||||||
password: createForm.password,
|
password: createForm.password,
|
||||||
role: createForm.role,
|
role: createForm.role,
|
||||||
plan_code: createForm.plan_code,
|
plan_code: createForm.plan_code,
|
||||||
|
brand_limit: createForm.brand_limit,
|
||||||
|
question_limit: createForm.question_limit,
|
||||||
})
|
})
|
||||||
message.success('新建成功')
|
message.success('新建成功')
|
||||||
resetCreate()
|
resetCreate()
|
||||||
@@ -542,6 +586,8 @@ const editForm = reactive({
|
|||||||
role: 'tenant_admin',
|
role: 'tenant_admin',
|
||||||
plan_code: '',
|
plan_code: '',
|
||||||
subscription_end_date: '',
|
subscription_end_date: '',
|
||||||
|
brand_limit: null as number | null,
|
||||||
|
question_limit: null as number | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
function openEdit(row: AdminUserRow) {
|
function openEdit(row: AdminUserRow) {
|
||||||
@@ -551,6 +597,8 @@ function openEdit(row: AdminUserRow) {
|
|||||||
editForm.email = row.email ?? ''
|
editForm.email = row.email ?? ''
|
||||||
editForm.role = row.tenant_role ?? 'tenant_admin'
|
editForm.role = row.tenant_role ?? 'tenant_admin'
|
||||||
editForm.plan_code = row.plan_code ?? defaultPlanCode()
|
editForm.plan_code = row.plan_code ?? defaultPlanCode()
|
||||||
|
editForm.brand_limit = row.brand_limit
|
||||||
|
editForm.question_limit = row.question_limit
|
||||||
editForm.subscription_end_date = row.subscription_end_at
|
editForm.subscription_end_date = row.subscription_end_at
|
||||||
? dayjs(row.subscription_end_at).format('YYYY-MM-DD')
|
? dayjs(row.subscription_end_at).format('YYYY-MM-DD')
|
||||||
: estimatePlanEndDate(editForm.plan_code)
|
: estimatePlanEndDate(editForm.plan_code)
|
||||||
@@ -604,6 +652,10 @@ async function submitEdit() {
|
|||||||
end_at: subscriptionEndAt,
|
end_at: subscriptionEndAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
await http.put(`/admin-users/${editTarget.value.id}/limits`, {
|
||||||
|
brand_limit: editForm.brand_limit,
|
||||||
|
question_limit: editForm.question_limit,
|
||||||
|
})
|
||||||
message.success('用户设置已更新')
|
message.success('用户设置已更新')
|
||||||
editOpen.value = false
|
editOpen.value = false
|
||||||
void reload()
|
void reload()
|
||||||
@@ -818,6 +870,10 @@ onMounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-users-limit-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-users-toolbar__spacer {
|
.admin-users-toolbar__spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
ActionAdminUserCreate = "admin_user.create"
|
ActionAdminUserCreate = "admin_user.create"
|
||||||
ActionAdminUserUpdateProfile = "admin_user.update_profile"
|
ActionAdminUserUpdateProfile = "admin_user.update_profile"
|
||||||
|
ActionAdminUserUpdateLimits = "admin_user.update_limits"
|
||||||
ActionAdminUserChangePlan = "admin_user.change_plan"
|
ActionAdminUserChangePlan = "admin_user.change_plan"
|
||||||
ActionAdminUserChangeRole = "admin_user.change_role"
|
ActionAdminUserChangeRole = "admin_user.change_role"
|
||||||
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
|
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
|
||||||
@@ -101,6 +102,8 @@ type AdminUserView struct {
|
|||||||
TenantID *int64 `json:"tenant_id"`
|
TenantID *int64 `json:"tenant_id"`
|
||||||
TenantName *string `json:"tenant_name"`
|
TenantName *string `json:"tenant_name"`
|
||||||
TenantStatus *string `json:"tenant_status"`
|
TenantStatus *string `json:"tenant_status"`
|
||||||
|
BrandLimit *int `json:"brand_limit"`
|
||||||
|
QuestionLimit *int `json:"question_limit"`
|
||||||
TenantRole *string `json:"tenant_role"`
|
TenantRole *string `json:"tenant_role"`
|
||||||
PrimaryWorkspaceID *int64 `json:"primary_workspace_id"`
|
PrimaryWorkspaceID *int64 `json:"primary_workspace_id"`
|
||||||
PlanCode *string `json:"plan_code"`
|
PlanCode *string `json:"plan_code"`
|
||||||
@@ -154,6 +157,8 @@ type CreateAdminUserInput struct {
|
|||||||
Name string
|
Name string
|
||||||
PlanCode string
|
PlanCode string
|
||||||
Role string
|
Role string
|
||||||
|
BrandLimit *int
|
||||||
|
QuestionLimit *int
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateAdminUserInput struct {
|
type UpdateAdminUserInput struct {
|
||||||
@@ -162,6 +167,11 @@ type UpdateAdminUserInput struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateAdminUserLimitsInput struct {
|
||||||
|
BrandLimit *int
|
||||||
|
QuestionLimit *int
|
||||||
|
}
|
||||||
|
|
||||||
type SetAdminUserKOLInput struct {
|
type SetAdminUserKOLInput struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
DisplayName string
|
DisplayName string
|
||||||
@@ -205,6 +215,8 @@ func toAdminUserView(u *repository.AdminUserRecord) AdminUserView {
|
|||||||
TenantID: u.TenantID,
|
TenantID: u.TenantID,
|
||||||
TenantName: u.TenantName,
|
TenantName: u.TenantName,
|
||||||
TenantStatus: u.TenantStatus,
|
TenantStatus: u.TenantStatus,
|
||||||
|
BrandLimit: u.BrandLimit,
|
||||||
|
QuestionLimit: u.QuestionLimit,
|
||||||
TenantRole: u.TenantRole,
|
TenantRole: u.TenantRole,
|
||||||
PrimaryWorkspaceID: u.PrimaryWorkspaceID,
|
PrimaryWorkspaceID: u.PrimaryWorkspaceID,
|
||||||
PlanCode: u.PlanCode,
|
PlanCode: u.PlanCode,
|
||||||
@@ -332,6 +344,12 @@ func (s *AdminUserService) Get(ctx context.Context, id int64) (*AdminUserView, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAdminUserInput) (*AdminUserView, error) {
|
func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAdminUserInput) (*AdminUserView, error) {
|
||||||
|
if err := validateOptionalAdminUserLimit("brand_limit", in.BrandLimit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateOptionalAdminUserLimit("question_limit", in.QuestionLimit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
phone, err := normalizePhone(in.Phone)
|
phone, err := normalizePhone(in.Phone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -370,6 +388,8 @@ func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAd
|
|||||||
PlanCode: planCode,
|
PlanCode: planCode,
|
||||||
TenantRole: role,
|
TenantRole: role,
|
||||||
WorkspaceRole: workspaceRoleForTenantRole(role),
|
WorkspaceRole: workspaceRoleForTenantRole(role),
|
||||||
|
BrandLimit: in.BrandLimit,
|
||||||
|
QuestionLimit: in.QuestionLimit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, s.mapWriteError(err)
|
return nil, s.mapWriteError(err)
|
||||||
@@ -383,6 +403,8 @@ func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAd
|
|||||||
"plan_code": planCode,
|
"plan_code": planCode,
|
||||||
"tenant_role": role,
|
"tenant_role": role,
|
||||||
"tenant_id": user.TenantID,
|
"tenant_id": user.TenantID,
|
||||||
|
"brand_limit": in.BrandLimit,
|
||||||
|
"question_limit": in.QuestionLimit,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +444,44 @@ func (s *AdminUserService) Update(ctx context.Context, actor *Actor, id int64, i
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) UpdateLimits(ctx context.Context, actor *Actor, id int64, in UpdateAdminUserLimitsInput) (*AdminUserView, error) {
|
||||||
|
if err := validateOptionalAdminUserLimit("brand_limit", in.BrandLimit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateOptionalAdminUserLimit("question_limit", in.QuestionLimit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.users.UpdateLimits(ctx, id, in.BrandLimit, in.QuestionLimit)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||||
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50042, "update_failed", "failed to update user limits")
|
||||||
|
}
|
||||||
|
if user.TenantID != nil {
|
||||||
|
s.invalidateQuotaSummaryCache(ctx, *user.TenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if actor != nil {
|
||||||
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserUpdateLimits, "admin_user", id, map[string]any{
|
||||||
|
"tenant_id": user.TenantID,
|
||||||
|
"brand_limit": in.BrandLimit,
|
||||||
|
"question_limit": in.QuestionLimit,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
view := toAdminUserView(user)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOptionalAdminUserLimit(field string, value *int) error {
|
||||||
|
if value != nil && *value <= 0 {
|
||||||
|
return response.ErrBadRequest(40041, "invalid_limit", field+" must be a positive integer or null")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AdminUserService) ChangePlan(ctx context.Context, actor *Actor, id int64, planCode string) (*AdminUserView, error) {
|
func (s *AdminUserService) ChangePlan(ctx context.Context, actor *Actor, id int64, planCode string) (*AdminUserView, error) {
|
||||||
normalizedPlanCode := strings.TrimSpace(planCode)
|
normalizedPlanCode := strings.TrimSpace(planCode)
|
||||||
if normalizedPlanCode == "" {
|
if normalizedPlanCode == "" {
|
||||||
@@ -529,6 +589,7 @@ func (s *AdminUserService) invalidateQuotaSummaryCache(ctx context.Context, tena
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.cache.Delete(ctx, fmt.Sprintf("workspace:quota_summary:%d", tenantID))
|
_ = s.cache.Delete(ctx, fmt.Sprintf("workspace:quota_summary:%d", tenantID))
|
||||||
|
_ = s.cache.Delete(ctx, fmt.Sprintf("brand:library_summary:%d", tenantID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AdminUserService) ChangeRole(ctx context.Context, actor *Actor, id int64, role string) (*AdminUserView, error) {
|
func (s *AdminUserService) ChangeRole(ctx context.Context, actor *Actor, id int64, role string) (*AdminUserView, error) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func TestAdminUserInvalidateQuotaSummaryCache(t *testing.T) {
|
|||||||
|
|
||||||
svc.invalidateQuotaSummaryCache(context.Background(), 42)
|
svc.invalidateQuotaSummaryCache(context.Background(), 42)
|
||||||
|
|
||||||
if want := []string{"workspace:quota_summary:42"}; !reflect.DeepEqual(cache.deletedKeys, want) {
|
if want := []string{"workspace:quota_summary:42", "brand:library_summary:42"}; !reflect.DeepEqual(cache.deletedKeys, want) {
|
||||||
t.Fatalf("deleted keys = %v, want %v", cache.deletedKeys, want)
|
t.Fatalf("deleted keys = %v, want %v", cache.deletedKeys, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateOptionalAdminUserLimit(t *testing.T) {
|
||||||
|
positive := 3
|
||||||
|
zero := 0
|
||||||
|
negative := -1
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value *int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "null inherits config", value: nil},
|
||||||
|
{name: "positive override", value: &positive},
|
||||||
|
{name: "zero rejected", value: &zero, wantErr: true},
|
||||||
|
{name: "negative rejected", value: &negative, wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validateOptionalAdminUserLimit("brand_limit", tt.value)
|
||||||
|
if !tt.wantErr {
|
||||||
|
require.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var appErr *response.AppError
|
||||||
|
require.ErrorAs(t, err, &appErr)
|
||||||
|
assert.Equal(t, "invalid_limit", appErr.Message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ type AdminUserRecord struct {
|
|||||||
TenantID *int64
|
TenantID *int64
|
||||||
TenantName *string
|
TenantName *string
|
||||||
TenantStatus *string
|
TenantStatus *string
|
||||||
|
BrandLimit *int
|
||||||
|
QuestionLimit *int
|
||||||
TenantRole *string
|
TenantRole *string
|
||||||
PrimaryWorkspaceID *int64
|
PrimaryWorkspaceID *int64
|
||||||
PlanCode *string
|
PlanCode *string
|
||||||
@@ -76,6 +78,8 @@ type CreateAdminUserInput struct {
|
|||||||
PlanCode string
|
PlanCode string
|
||||||
TenantRole string
|
TenantRole string
|
||||||
WorkspaceRole string
|
WorkspaceRole string
|
||||||
|
BrandLimit *int
|
||||||
|
QuestionLimit *int
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateAdminUserProfileInput struct {
|
type UpdateAdminUserProfileInput struct {
|
||||||
@@ -106,6 +110,8 @@ u.status,
|
|||||||
tm.tenant_id,
|
tm.tenant_id,
|
||||||
t.name AS tenant_name,
|
t.name AS tenant_name,
|
||||||
t.status AS tenant_status,
|
t.status AS tenant_status,
|
||||||
|
t.brand_limit,
|
||||||
|
t.question_limit,
|
||||||
tm.tenant_role,
|
tm.tenant_role,
|
||||||
wm.workspace_id AS primary_workspace_id,
|
wm.workspace_id AS primary_workspace_id,
|
||||||
sub.plan_code,
|
sub.plan_code,
|
||||||
@@ -186,6 +192,8 @@ func scanAdminUser(row pgx.Row) (*AdminUserRecord, error) {
|
|||||||
&u.TenantID,
|
&u.TenantID,
|
||||||
&u.TenantName,
|
&u.TenantName,
|
||||||
&u.TenantStatus,
|
&u.TenantStatus,
|
||||||
|
&u.BrandLimit,
|
||||||
|
&u.QuestionLimit,
|
||||||
&u.TenantRole,
|
&u.TenantRole,
|
||||||
&u.PrimaryWorkspaceID,
|
&u.PrimaryWorkspaceID,
|
||||||
&u.PlanCode,
|
&u.PlanCode,
|
||||||
@@ -333,9 +341,9 @@ RETURNING id`, in.Email, in.Phone, in.PasswordHash, in.Name).Scan(&userID); err
|
|||||||
var tenantID int64
|
var tenantID int64
|
||||||
tenantName := fmt.Sprintf("user-%d", userID)
|
tenantName := fmt.Sprintf("user-%d", userID)
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
INSERT INTO tenants (name, status)
|
INSERT INTO tenants (name, status, brand_limit, question_limit)
|
||||||
VALUES ($1, 'active')
|
VALUES ($1, 'active', $2, $3)
|
||||||
RETURNING id`, tenantName).Scan(&tenantID); err != nil {
|
RETURNING id`, tenantName, in.BrandLimit, in.QuestionLimit).Scan(&tenantID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,6 +790,36 @@ WHERE id = $4
|
|||||||
return nil
|
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 {
|
func (r *AdminUserRepository) UpdateStatus(ctx context.Context, id int64, status string) error {
|
||||||
cmd, err := r.pool.Exec(ctx, `
|
cmd, err := r.pool.Exec(ctx, `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type createAdminUserRequest struct {
|
|||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
PlanCode string `json:"plan_code"`
|
PlanCode string `json:"plan_code"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
BrandLimit *int `json:"brand_limit"`
|
||||||
|
QuestionLimit *int `json:"question_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateAdminUserRequest struct {
|
type updateAdminUserRequest struct {
|
||||||
@@ -25,6 +27,11 @@ type updateAdminUserRequest struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type updateAdminUserLimitsRequest struct {
|
||||||
|
BrandLimit *int `json:"brand_limit"`
|
||||||
|
QuestionLimit *int `json:"question_limit"`
|
||||||
|
}
|
||||||
|
|
||||||
type changeAdminUserPlanRequest struct {
|
type changeAdminUserPlanRequest struct {
|
||||||
PlanCode string `json:"plan_code" binding:"required"`
|
PlanCode string `json:"plan_code" binding:"required"`
|
||||||
}
|
}
|
||||||
@@ -92,6 +99,8 @@ func createAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
|||||||
Password: body.Password,
|
Password: body.Password,
|
||||||
PlanCode: body.PlanCode,
|
PlanCode: body.PlanCode,
|
||||||
Role: body.Role,
|
Role: body.Role,
|
||||||
|
BrandLimit: body.BrandLimit,
|
||||||
|
QuestionLimit: body.QuestionLimit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, err)
|
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 {
|
func changeAdminUserPlanHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id, err := parseIDParam(c)
|
id, err := parseIDParam(c)
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ func RegisterRoutes(d Deps) {
|
|||||||
authed.POST("/admin-users", createAdminUserHandler(d.AdminUsers))
|
authed.POST("/admin-users", createAdminUserHandler(d.AdminUsers))
|
||||||
authed.GET("/admin-users/:id", getAdminUserHandler(d.AdminUsers))
|
authed.GET("/admin-users/:id", getAdminUserHandler(d.AdminUsers))
|
||||||
authed.PATCH("/admin-users/:id", updateAdminUserHandler(d.AdminUsers))
|
authed.PATCH("/admin-users/:id", updateAdminUserHandler(d.AdminUsers))
|
||||||
|
authed.PUT("/admin-users/:id/limits", updateAdminUserLimitsHandler(d.AdminUsers))
|
||||||
authed.POST("/admin-users/:id/plan", changeAdminUserPlanHandler(d.AdminUsers))
|
authed.POST("/admin-users/:id/plan", changeAdminUserPlanHandler(d.AdminUsers))
|
||||||
authed.POST("/admin-users/:id/subscription-expiry", updateAdminUserSubscriptionExpiryHandler(d.AdminUsers))
|
authed.POST("/admin-users/:id/subscription-expiry", updateAdminUserSubscriptionExpiryHandler(d.AdminUsers))
|
||||||
authed.POST("/admin-users/:id/reset-plan-usage", resetAdminUserPlanUsageHandler(d.AdminUsers))
|
authed.POST("/admin-users/:id/reset-plan-usage", resetAdminUserPlanUsageHandler(d.AdminUsers))
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ var routeDocs = map[string]routeDoc{
|
|||||||
"POST /api/ops/admin-users": {"新建租户管理员", "创建租户管理员账号。"},
|
"POST /api/ops/admin-users": {"新建租户管理员", "创建租户管理员账号。"},
|
||||||
"GET /api/ops/admin-users/:id": {"租户管理员详情", "读取租户管理员账号详情。"},
|
"GET /api/ops/admin-users/:id": {"租户管理员详情", "读取租户管理员账号详情。"},
|
||||||
"PATCH /api/ops/admin-users/:id": {"更新租户管理员", "更新租户管理员基础资料。"},
|
"PATCH /api/ops/admin-users/:id": {"更新租户管理员", "更新租户管理员基础资料。"},
|
||||||
|
"PUT /api/ops/admin-users/:id/limits": {"调整品牌库限额", "设置租户管理员账号的品牌和问题数量覆盖限额。"},
|
||||||
"POST /api/ops/admin-users/:id/plan": {"调整租户套餐", "调整租户管理员账号绑定的套餐。"},
|
"POST /api/ops/admin-users/:id/plan": {"调整租户套餐", "调整租户管理员账号绑定的套餐。"},
|
||||||
"POST /api/ops/admin-users/:id/subscription-expiry": {"调整订阅到期时间", "更新租户管理员账号订阅到期时间。"},
|
"POST /api/ops/admin-users/:id/subscription-expiry": {"调整订阅到期时间", "更新租户管理员账号订阅到期时间。"},
|
||||||
"POST /api/ops/admin-users/:id/reset-plan-usage": {"重置套餐用量", "重置租户管理员账号套餐用量。"},
|
"POST /api/ops/admin-users/:id/reset-plan-usage": {"重置套餐用量", "重置租户管理员账号套餐用量。"},
|
||||||
|
|||||||
@@ -1156,6 +1156,13 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand
|
|||||||
type brandLibraryPlan struct {
|
type brandLibraryPlan struct {
|
||||||
PlanCode string
|
PlanCode string
|
||||||
PlanName string
|
PlanName string
|
||||||
|
BrandLimit *int
|
||||||
|
QuestionLimit *int
|
||||||
|
}
|
||||||
|
|
||||||
|
type brandLibraryEffectiveLimits struct {
|
||||||
|
MaxBrands int
|
||||||
|
MaxQuestions int
|
||||||
}
|
}
|
||||||
|
|
||||||
type brandLibraryUsage struct {
|
type brandLibraryUsage struct {
|
||||||
@@ -1176,27 +1183,47 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
|
|||||||
}
|
}
|
||||||
|
|
||||||
limits := s.currentLimits()
|
limits := s.currentLimits()
|
||||||
maxBrands := limits.BrandLimitForPlan(plan.PlanCode)
|
effectiveLimits := resolveBrandLibraryLimits(limits, plan)
|
||||||
maxKeywords := limits.MaxKeywords
|
maxKeywords := limits.MaxKeywords
|
||||||
maxQuestions := limits.QuestionLimitForPlan(plan.PlanCode)
|
|
||||||
|
|
||||||
return &BrandLibrarySummaryResponse{
|
return &BrandLibrarySummaryResponse{
|
||||||
PlanCode: plan.PlanCode,
|
PlanCode: plan.PlanCode,
|
||||||
PlanName: plan.PlanName,
|
PlanName: plan.PlanName,
|
||||||
MaxBrands: maxBrands,
|
MaxBrands: effectiveLimits.MaxBrands,
|
||||||
UsedBrands: usage.BrandCount,
|
UsedBrands: usage.BrandCount,
|
||||||
RemainingBrands: maxInt(maxBrands-usage.BrandCount, 0),
|
RemainingBrands: maxInt(effectiveLimits.MaxBrands-usage.BrandCount, 0),
|
||||||
MaxKeywords: maxKeywords,
|
MaxKeywords: maxKeywords,
|
||||||
UsedKeywords: usage.KeywordCount,
|
UsedKeywords: usage.KeywordCount,
|
||||||
RemainingKeywords: maxInt(maxKeywords-usage.KeywordCount, 0),
|
RemainingKeywords: maxInt(maxKeywords-usage.KeywordCount, 0),
|
||||||
MaxQuestions: maxQuestions,
|
MaxQuestions: effectiveLimits.MaxQuestions,
|
||||||
UsedQuestions: usage.QuestionCount,
|
UsedQuestions: usage.QuestionCount,
|
||||||
RemainingQuestions: maxInt(maxQuestions-usage.QuestionCount, 0),
|
RemainingQuestions: maxInt(effectiveLimits.MaxQuestions-usage.QuestionCount, 0),
|
||||||
MaxQuestionsPerKeyword: limits.MaxQuestionsPerKeyword,
|
MaxQuestionsPerKeyword: limits.MaxQuestionsPerKeyword,
|
||||||
MaxQuestionsPerBrand: maxQuestions,
|
MaxQuestionsPerBrand: effectiveLimits.MaxQuestions,
|
||||||
}, nil
|
}, 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) {
|
func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) {
|
||||||
plan := &brandLibraryPlan{
|
plan := &brandLibraryPlan{
|
||||||
PlanCode: "free",
|
PlanCode: "free",
|
||||||
@@ -1204,17 +1231,27 @@ func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64)
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
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
|
SELECT p.plan_code, p.name
|
||||||
FROM tenant_plan_subscriptions s
|
FROM tenant_plan_subscriptions s
|
||||||
JOIN plans p ON p.id = s.plan_id
|
JOIN plans p ON p.id = s.plan_id
|
||||||
WHERE s.tenant_id = $1
|
WHERE s.tenant_id = t.id
|
||||||
AND s.status = 'active'
|
AND s.status = 'active'
|
||||||
AND s.deleted_at IS NULL
|
AND s.deleted_at IS NULL
|
||||||
AND p.status = 'active'
|
AND p.status = 'active'
|
||||||
AND s.end_at > $2
|
AND s.end_at > $2
|
||||||
ORDER BY s.start_at DESC
|
ORDER BY s.start_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName)
|
) 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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return plan, nil
|
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.id AS primary_client_id,
|
||||||
dc.created_at,
|
dc.created_at,
|
||||||
p.plan_code,
|
p.plan_code,
|
||||||
p.quota_policy_json
|
p.quota_policy_json,
|
||||||
|
t.brand_limit
|
||||||
FROM desktop_clients dc
|
FROM desktop_clients dc
|
||||||
|
JOIN tenants t
|
||||||
|
ON t.id = dc.tenant_id
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
JOIN tenant_plan_subscriptions s
|
JOIN tenant_plan_subscriptions s
|
||||||
ON s.tenant_id = dc.tenant_id
|
ON s.tenant_id = dc.tenant_id
|
||||||
AND s.status = 'active'
|
AND s.status = 'active'
|
||||||
@@ -697,7 +701,8 @@ func dailyMonitoringPrimaryClientPlansSQL() string {
|
|||||||
ec.workspace_id,
|
ec.workspace_id,
|
||||||
ec.primary_client_id,
|
ec.primary_client_id,
|
||||||
ec.plan_code,
|
ec.plan_code,
|
||||||
ec.quota_policy_json
|
ec.quota_policy_json,
|
||||||
|
ec.brand_limit
|
||||||
FROM eligible_clients ec
|
FROM eligible_clients ec
|
||||||
LEFT JOIN desktop_client_primary_leases l
|
LEFT JOIN desktop_client_primary_leases l
|
||||||
ON l.tenant_id = ec.tenant_id
|
ON l.tenant_id = ec.tenant_id
|
||||||
@@ -717,6 +722,7 @@ func dailyMonitoringPrimaryClientPlansSQL() string {
|
|||||||
c.primary_client_id,
|
c.primary_client_id,
|
||||||
GREATEST(
|
GREATEST(
|
||||||
COALESCE(
|
COALESCE(
|
||||||
|
c.brand_limit,
|
||||||
NULLIF((c.quota_policy_json ->> 'brand_limit')::int, 0),
|
NULLIF((c.quota_policy_json ->> 'brand_limit')::int, 0),
|
||||||
CASE WHEN c.plan_code = 'free' THEN $1::int ELSE $2::int END
|
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) {
|
func TestDailyMonitoringPrimaryClientPlansSQLUsesContiguousParameters(t *testing.T) {
|
||||||
sql := dailyMonitoringPrimaryClientPlansSQL()
|
sql := dailyMonitoringPrimaryClientPlansSQL()
|
||||||
|
normalized := normalizeSQLWhitespace(sql)
|
||||||
|
|
||||||
assert.Contains(t, normalizeSQLWhitespace(sql), "pa.platform_id = ANY($3::text[])")
|
assert.Contains(t, normalized, "pa.platform_id = ANY($3::text[])")
|
||||||
assert.Contains(t, normalizeSQLWhitespace(sql), "dc.last_seen_at >= NOW() - $4::interval")
|
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")
|
assert.NotContains(t, sql, "$5")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ type questionBrandContext struct {
|
|||||||
BrandName string
|
BrandName string
|
||||||
Website *string
|
Website *string
|
||||||
Description *string
|
Description *string
|
||||||
PlanCode string
|
QuestionLimit int
|
||||||
CompetitorNames []string
|
CompetitorNames []string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +473,7 @@ func (s *QuestionExpansionService) MaterializeQuestions(ctx context.Context, bra
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
maxQuestions := s.brand.currentLimits().QuestionLimitForPlan(brandCtx.PlanCode)
|
maxQuestions := brandCtx.QuestionLimit
|
||||||
remaining := maxQuestions - currentCount
|
remaining := maxQuestions - currentCount
|
||||||
if remaining < 0 {
|
if remaining < 0 {
|
||||||
remaining = 0
|
remaining = 0
|
||||||
@@ -663,7 +663,7 @@ func (s *QuestionExpansionService) loadQuestionBrandContext(ctx context.Context,
|
|||||||
BrandName: brandName,
|
BrandName: brandName,
|
||||||
Website: website,
|
Website: website,
|
||||||
Description: description,
|
Description: description,
|
||||||
PlanCode: plan.PlanCode,
|
QuestionLimit: resolveBrandLibraryLimits(s.brand.currentLimits(), plan).MaxQuestions,
|
||||||
CompetitorNames: competitors,
|
CompetitorNames: competitors,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1060,6 +1060,8 @@ type Tenant struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
BrandLimit pgtype.Int4 `json:"brand_limit"`
|
||||||
|
QuestionLimit pgtype.Int4 `json:"question_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TenantImageStorageUsage struct {
|
type TenantImageStorageUsage struct {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE tenants
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_tenants_question_limit_positive,
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_tenants_brand_limit_positive,
|
||||||
|
DROP COLUMN IF EXISTS question_limit,
|
||||||
|
DROP COLUMN IF EXISTS brand_limit;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE tenants
|
||||||
|
ADD COLUMN brand_limit INT,
|
||||||
|
ADD COLUMN question_limit INT,
|
||||||
|
ADD CONSTRAINT chk_tenants_brand_limit_positive
|
||||||
|
CHECK (brand_limit IS NULL OR brand_limit > 0),
|
||||||
|
ADD CONSTRAINT chk_tenants_question_limit_positive
|
||||||
|
CHECK (question_limit IS NULL OR question_limit > 0);
|
||||||
Reference in New Issue
Block a user