package store import ( "context" "encoding/json" "strings" "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) type GatewayTenantInput struct { TenantKey string `json:"tenantKey"` Source string `json:"source"` ExternalTenantID string `json:"externalTenantId"` Name string `json:"name"` Description string `json:"description"` DefaultUserGroupID string `json:"defaultUserGroupId"` PlanKey string `json:"planKey"` BillingProfile map[string]any `json:"billingProfile"` RateLimitPolicy map[string]any `json:"rateLimitPolicy"` AuthPolicy map[string]any `json:"authPolicy"` Metadata map[string]any `json:"metadata"` Status string `json:"status"` } type GatewayUserInput struct { UserKey string `json:"userKey"` Source string `json:"source"` ExternalUserID string `json:"externalUserId"` Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` Phone string `json:"phone"` AvatarURL string `json:"avatarUrl"` Password string `json:"password"` GatewayTenantID string `json:"gatewayTenantId"` TenantID string `json:"tenantId"` TenantKey string `json:"tenantKey"` DefaultUserGroupID string `json:"defaultUserGroupId"` Roles []string `json:"roles"` AuthProfile map[string]any `json:"authProfile"` Metadata map[string]any `json:"metadata"` Status string `json:"status"` } type UserGroupInput struct { GroupKey string `json:"groupKey"` Name string `json:"name"` Description string `json:"description"` Source string `json:"source"` Priority int `json:"priority"` RechargeDiscountPolicy map[string]any `json:"rechargeDiscountPolicy"` BillingDiscountPolicy map[string]any `json:"billingDiscountPolicy"` RateLimitPolicy map[string]any `json:"rateLimitPolicy"` QuotaPolicy map[string]any `json:"quotaPolicy"` Metadata map[string]any `json:"metadata"` Status string `json:"status"` } func (s *Store) CreateTenant(ctx context.Context, input GatewayTenantInput) (GatewayTenant, error) { input = normalizeTenantInput(input) billingProfile, _ := json.Marshal(emptyObjectIfNil(input.BillingProfile)) rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy)) authPolicy, _ := json.Marshal(emptyObjectIfNil(input.AuthPolicy)) metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) return scanTenant(s.pool.QueryRow(ctx, ` INSERT INTO gateway_tenants ( tenant_key, source, external_tenant_id, name, description, default_user_group_id, plan_key, billing_profile, rate_limit_policy, auth_policy, metadata, status ) VALUES ($1, $2, NULLIF($3, ''), $4, NULLIF($5, ''), NULLIF($6, '')::uuid, NULLIF($7, ''), $8, $9, $10, $11, $12) RETURNING `+tenantColumns, input.TenantKey, input.Source, input.ExternalTenantID, input.Name, input.Description, input.DefaultUserGroupID, input.PlanKey, billingProfile, rateLimitPolicy, authPolicy, metadata, input.Status, )) } func (s *Store) UpdateTenant(ctx context.Context, id string, input GatewayTenantInput) (GatewayTenant, error) { input = normalizeTenantInput(input) billingProfile, _ := json.Marshal(emptyObjectIfNil(input.BillingProfile)) rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy)) authPolicy, _ := json.Marshal(emptyObjectIfNil(input.AuthPolicy)) metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) return scanTenant(s.pool.QueryRow(ctx, ` UPDATE gateway_tenants SET tenant_key = $2, source = $3, external_tenant_id = NULLIF($4, ''), name = $5, description = NULLIF($6, ''), default_user_group_id = NULLIF($7, '')::uuid, plan_key = NULLIF($8, ''), billing_profile = $9, rate_limit_policy = $10, auth_policy = $11, metadata = $12, status = $13, updated_at = now() WHERE id = $1::uuid AND deleted_at IS NULL RETURNING `+tenantColumns, id, input.TenantKey, input.Source, input.ExternalTenantID, input.Name, input.Description, input.DefaultUserGroupID, input.PlanKey, billingProfile, rateLimitPolicy, authPolicy, metadata, input.Status, )) } func (s *Store) DeleteTenant(ctx context.Context, id string) error { result, err := s.pool.Exec(ctx, ` UPDATE gateway_tenants SET deleted_at = now(), status = 'deleted', tenant_key = tenant_key || ':deleted:' || left(id::text, 8), external_tenant_id = NULL, updated_at = now() WHERE id = $1::uuid AND deleted_at IS NULL`, id) if err != nil { return err } if result.RowsAffected() == 0 { return pgx.ErrNoRows } return nil } func (s *Store) CreateGatewayUser(ctx context.Context, input GatewayUserInput) (GatewayUser, error) { input = normalizeUserInput(input) passwordHash := "" if strings.TrimSpace(input.Password) != "" { hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) if err != nil { return GatewayUser{}, err } passwordHash = string(hash) } roles, _ := json.Marshal(input.Roles) authProfile, _ := json.Marshal(emptyObjectIfNil(input.AuthProfile)) metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) return scanUser(s.pool.QueryRow(ctx, ` INSERT INTO gateway_users ( user_key, source, external_user_id, username, display_name, email, phone, avatar_url, password_hash, gateway_tenant_id, tenant_id, tenant_key, default_user_group_id, roles, auth_profile, metadata, status ) VALUES ( $1, $2, NULLIF($3, ''), $4, NULLIF($5, ''), NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, '')::uuid, NULLIF($11, ''), NULLIF($12, ''), NULLIF($13, '')::uuid, $14, $15, $16, $17 ) RETURNING `+userColumns, input.UserKey, input.Source, input.ExternalUserID, input.Username, input.DisplayName, input.Email, input.Phone, input.AvatarURL, passwordHash, input.GatewayTenantID, input.TenantID, input.TenantKey, input.DefaultUserGroupID, roles, authProfile, metadata, input.Status, )) } func (s *Store) UpdateGatewayUser(ctx context.Context, id string, input GatewayUserInput) (GatewayUser, error) { input = normalizeUserInput(input) passwordHash := "" if strings.TrimSpace(input.Password) != "" { hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) if err != nil { return GatewayUser{}, err } passwordHash = string(hash) } roles, _ := json.Marshal(input.Roles) authProfile, _ := json.Marshal(emptyObjectIfNil(input.AuthProfile)) metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) return scanUser(s.pool.QueryRow(ctx, ` UPDATE gateway_users SET user_key = $2, source = $3, external_user_id = NULLIF($4, ''), username = $5, display_name = NULLIF($6, ''), email = NULLIF($7, ''), phone = NULLIF($8, ''), avatar_url = NULLIF($9, ''), password_hash = COALESCE(NULLIF($10, ''), password_hash), gateway_tenant_id = NULLIF($11, '')::uuid, tenant_id = NULLIF($12, ''), tenant_key = NULLIF($13, ''), default_user_group_id = NULLIF($14, '')::uuid, roles = $15, auth_profile = $16, metadata = $17, status = $18, updated_at = now() WHERE id = $1::uuid AND deleted_at IS NULL RETURNING `+userColumns, id, input.UserKey, input.Source, input.ExternalUserID, input.Username, input.DisplayName, input.Email, input.Phone, input.AvatarURL, passwordHash, input.GatewayTenantID, input.TenantID, input.TenantKey, input.DefaultUserGroupID, roles, authProfile, metadata, input.Status, )) } func (s *Store) DeleteGatewayUser(ctx context.Context, id string) error { result, err := s.pool.Exec(ctx, ` UPDATE gateway_users SET deleted_at = now(), status = 'deleted', user_key = user_key || ':deleted:' || left(id::text, 8), external_user_id = NULL, email = NULL, updated_at = now() WHERE id = $1::uuid AND deleted_at IS NULL`, id) if err != nil { return err } if result.RowsAffected() == 0 { return pgx.ErrNoRows } return nil } func (s *Store) CreateUserGroup(ctx context.Context, input UserGroupInput) (UserGroup, error) { input = normalizeUserGroupInput(input) rechargeDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.RechargeDiscountPolicy)) billingDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.BillingDiscountPolicy)) rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy)) quotaPolicy, _ := json.Marshal(emptyObjectIfNil(input.QuotaPolicy)) metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) return scanUserGroup(s.pool.QueryRow(ctx, ` INSERT INTO gateway_user_groups ( group_key, name, description, source, priority, recharge_discount_policy, billing_discount_policy, rate_limit_policy, quota_policy, metadata, status ) VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7, $8, $9, $10, $11) RETURNING `+userGroupColumns, input.GroupKey, input.Name, input.Description, input.Source, input.Priority, rechargeDiscountPolicy, billingDiscountPolicy, rateLimitPolicy, quotaPolicy, metadata, input.Status, )) } func (s *Store) UpdateUserGroup(ctx context.Context, id string, input UserGroupInput) (UserGroup, error) { input = normalizeUserGroupInput(input) rechargeDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.RechargeDiscountPolicy)) billingDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.BillingDiscountPolicy)) rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy)) quotaPolicy, _ := json.Marshal(emptyObjectIfNil(input.QuotaPolicy)) metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) return scanUserGroup(s.pool.QueryRow(ctx, ` UPDATE gateway_user_groups SET group_key = $2, name = $3, description = NULLIF($4, ''), source = $5, priority = $6, recharge_discount_policy = $7, billing_discount_policy = $8, rate_limit_policy = $9, quota_policy = $10, metadata = $11, status = $12, updated_at = now() WHERE id = $1::uuid RETURNING `+userGroupColumns, id, input.GroupKey, input.Name, input.Description, input.Source, input.Priority, rechargeDiscountPolicy, billingDiscountPolicy, rateLimitPolicy, quotaPolicy, metadata, input.Status, )) } func (s *Store) DeleteUserGroup(ctx context.Context, id string) error { result, err := s.pool.Exec(ctx, `DELETE FROM gateway_user_groups WHERE id = $1::uuid`, id) if err != nil { return err } if result.RowsAffected() == 0 { return pgx.ErrNoRows } return nil } const tenantColumns = ` id::text, tenant_key, source, COALESCE(external_tenant_id, ''), name, COALESCE(description, ''), COALESCE(default_user_group_id::text, ''), COALESCE(plan_key, ''), billing_profile, rate_limit_policy, auth_policy, metadata, status, COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''), created_at, updated_at` const userColumns = ` id::text, user_key, source, COALESCE(external_user_id, ''), username, COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''), COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata, status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''), created_at, updated_at` const userGroupColumns = ` id::text, group_key, name, COALESCE(description, ''), source, priority, recharge_discount_policy, billing_discount_policy, rate_limit_policy, quota_policy, metadata, status, created_at, updated_at` type scanner interface { Scan(dest ...any) error } func scanTenant(row scanner) (GatewayTenant, error) { var item GatewayTenant var billingProfile []byte var rateLimitPolicy []byte var authPolicy []byte var metadata []byte if err := row.Scan( &item.ID, &item.TenantKey, &item.Source, &item.ExternalTenantID, &item.Name, &item.Description, &item.DefaultUserGroupID, &item.PlanKey, &billingProfile, &rateLimitPolicy, &authPolicy, &metadata, &item.Status, &item.SyncedAt, &item.SourceUpdatedAt, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return GatewayTenant{}, err } item.BillingProfile = decodeObject(billingProfile) item.RateLimitPolicy = decodeObject(rateLimitPolicy) item.AuthPolicy = decodeObject(authPolicy) item.Metadata = decodeObject(metadata) return item, nil } func scanUser(row scanner) (GatewayUser, error) { var item GatewayUser var roles []byte var authProfile []byte var metadata []byte if err := row.Scan( &item.ID, &item.UserKey, &item.Source, &item.ExternalUserID, &item.Username, &item.DisplayName, &item.Email, &item.Phone, &item.AvatarURL, &item.GatewayTenantID, &item.TenantID, &item.TenantKey, &item.DefaultUserGroupID, &roles, &authProfile, &metadata, &item.Status, &item.LastLoginAt, &item.SyncedAt, &item.SourceUpdatedAt, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return GatewayUser{}, err } item.Roles = decodeStringArray(roles) item.AuthProfile = decodeObject(authProfile) item.Metadata = decodeObject(metadata) return item, nil } func scanUserGroup(row scanner) (UserGroup, error) { var item UserGroup var rechargeDiscountPolicy []byte var billingDiscountPolicy []byte var rateLimitPolicy []byte var quotaPolicy []byte var metadata []byte if err := row.Scan( &item.ID, &item.GroupKey, &item.Name, &item.Description, &item.Source, &item.Priority, &rechargeDiscountPolicy, &billingDiscountPolicy, &rateLimitPolicy, "aPolicy, &metadata, &item.Status, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return UserGroup{}, err } item.RechargeDiscountPolicy = decodeObject(rechargeDiscountPolicy) item.BillingDiscountPolicy = decodeObject(billingDiscountPolicy) item.RateLimitPolicy = decodeObject(rateLimitPolicy) item.QuotaPolicy = decodeObject(quotaPolicy) item.Metadata = decodeObject(metadata) return item, nil } func normalizeTenantInput(input GatewayTenantInput) GatewayTenantInput { input.TenantKey = strings.TrimSpace(input.TenantKey) input.Source = firstNonEmpty(strings.TrimSpace(input.Source), "gateway") input.Name = strings.TrimSpace(input.Name) input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active") return input } func normalizeUserInput(input GatewayUserInput) GatewayUserInput { input.Username = strings.TrimSpace(input.Username) input.Source = firstNonEmpty(strings.TrimSpace(input.Source), "gateway") input.UserKey = firstNonEmpty(strings.TrimSpace(input.UserKey), input.Source+":"+input.Username) input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active") input.Roles = normalizeGatewayRoles(input.Roles) return input } func normalizeGatewayRoles(roles []string) []string { for _, role := range roles { switch strings.TrimSpace(role) { case "manager": return []string{"manager"} case "admin": return []string{"admin"} case "operator": return []string{"operator"} case "creator": return []string{"creator"} case "user": return []string{"user"} } } return []string{"user"} } func normalizeUserGroupInput(input UserGroupInput) UserGroupInput { input.GroupKey = strings.TrimSpace(input.GroupKey) input.Name = strings.TrimSpace(input.Name) input.Source = firstNonEmpty(strings.TrimSpace(input.Source), "gateway") input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active") if input.Priority == 0 { input.Priority = 100 } return input }