feat(backend): add user custom attributes system
Add a flexible user attribute system that allows admins to define custom fields for users (text, textarea, number, email, url, date, select, multi_select types). - Add Ent schemas for UserAttributeDefinition and UserAttributeValue - Add service layer with validation logic - Add repository layer with batch operations support - Add admin API endpoints for CRUD and reorder operations - Add batch API for loading attribute values for multiple users - Add database migration (018_user_attributes.sql) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
125
backend/internal/service/user_attribute.go
Normal file
125
backend/internal/service/user_attribute.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// Error definitions for user attribute operations
|
||||
var (
|
||||
ErrAttributeDefinitionNotFound = infraerrors.NotFound("ATTRIBUTE_DEFINITION_NOT_FOUND", "attribute definition not found")
|
||||
ErrAttributeKeyExists = infraerrors.Conflict("ATTRIBUTE_KEY_EXISTS", "attribute key already exists")
|
||||
ErrInvalidAttributeType = infraerrors.BadRequest("INVALID_ATTRIBUTE_TYPE", "invalid attribute type")
|
||||
ErrAttributeValidationFailed = infraerrors.BadRequest("ATTRIBUTE_VALIDATION_FAILED", "attribute value validation failed")
|
||||
)
|
||||
|
||||
// UserAttributeType represents supported attribute types
|
||||
type UserAttributeType string
|
||||
|
||||
const (
|
||||
AttributeTypeText UserAttributeType = "text"
|
||||
AttributeTypeTextarea UserAttributeType = "textarea"
|
||||
AttributeTypeNumber UserAttributeType = "number"
|
||||
AttributeTypeEmail UserAttributeType = "email"
|
||||
AttributeTypeURL UserAttributeType = "url"
|
||||
AttributeTypeDate UserAttributeType = "date"
|
||||
AttributeTypeSelect UserAttributeType = "select"
|
||||
AttributeTypeMultiSelect UserAttributeType = "multi_select"
|
||||
)
|
||||
|
||||
// UserAttributeOption represents a select option for select/multi_select types
|
||||
type UserAttributeOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// UserAttributeValidation represents validation rules for an attribute
|
||||
type UserAttributeValidation struct {
|
||||
MinLength *int `json:"min_length,omitempty"`
|
||||
MaxLength *int `json:"max_length,omitempty"`
|
||||
Min *int `json:"min,omitempty"`
|
||||
Max *int `json:"max,omitempty"`
|
||||
Pattern *string `json:"pattern,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// UserAttributeDefinition represents a custom attribute definition
|
||||
type UserAttributeDefinition struct {
|
||||
ID int64
|
||||
Key string
|
||||
Name string
|
||||
Description string
|
||||
Type UserAttributeType
|
||||
Options []UserAttributeOption
|
||||
Required bool
|
||||
Validation UserAttributeValidation
|
||||
Placeholder string
|
||||
DisplayOrder int
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UserAttributeValue represents a user's attribute value
|
||||
type UserAttributeValue struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
AttributeID int64
|
||||
Value string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAttributeDefinitionInput for creating new definition
|
||||
type CreateAttributeDefinitionInput struct {
|
||||
Key string
|
||||
Name string
|
||||
Description string
|
||||
Type UserAttributeType
|
||||
Options []UserAttributeOption
|
||||
Required bool
|
||||
Validation UserAttributeValidation
|
||||
Placeholder string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// UpdateAttributeDefinitionInput for updating definition
|
||||
type UpdateAttributeDefinitionInput struct {
|
||||
Name *string
|
||||
Description *string
|
||||
Type *UserAttributeType
|
||||
Options *[]UserAttributeOption
|
||||
Required *bool
|
||||
Validation *UserAttributeValidation
|
||||
Placeholder *string
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
// UpdateUserAttributeInput for updating a single attribute value
|
||||
type UpdateUserAttributeInput struct {
|
||||
AttributeID int64
|
||||
Value string
|
||||
}
|
||||
|
||||
// UserAttributeDefinitionRepository interface for attribute definition persistence
|
||||
type UserAttributeDefinitionRepository interface {
|
||||
Create(ctx context.Context, def *UserAttributeDefinition) error
|
||||
GetByID(ctx context.Context, id int64) (*UserAttributeDefinition, error)
|
||||
GetByKey(ctx context.Context, key string) (*UserAttributeDefinition, error)
|
||||
Update(ctx context.Context, def *UserAttributeDefinition) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
List(ctx context.Context, enabledOnly bool) ([]UserAttributeDefinition, error)
|
||||
UpdateDisplayOrders(ctx context.Context, orders map[int64]int) error
|
||||
ExistsByKey(ctx context.Context, key string) (bool, error)
|
||||
}
|
||||
|
||||
// UserAttributeValueRepository interface for user attribute value persistence
|
||||
type UserAttributeValueRepository interface {
|
||||
GetByUserID(ctx context.Context, userID int64) ([]UserAttributeValue, error)
|
||||
GetByUserIDs(ctx context.Context, userIDs []int64) ([]UserAttributeValue, error)
|
||||
UpsertBatch(ctx context.Context, userID int64, values []UpdateUserAttributeInput) error
|
||||
DeleteByAttributeID(ctx context.Context, attributeID int64) error
|
||||
DeleteByUserID(ctx context.Context, userID int64) error
|
||||
}
|
||||
295
backend/internal/service/user_attribute_service.go
Normal file
295
backend/internal/service/user_attribute_service.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// UserAttributeService handles attribute management
|
||||
type UserAttributeService struct {
|
||||
defRepo UserAttributeDefinitionRepository
|
||||
valueRepo UserAttributeValueRepository
|
||||
}
|
||||
|
||||
// NewUserAttributeService creates a new service instance
|
||||
func NewUserAttributeService(
|
||||
defRepo UserAttributeDefinitionRepository,
|
||||
valueRepo UserAttributeValueRepository,
|
||||
) *UserAttributeService {
|
||||
return &UserAttributeService{
|
||||
defRepo: defRepo,
|
||||
valueRepo: valueRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDefinition creates a new attribute definition
|
||||
func (s *UserAttributeService) CreateDefinition(ctx context.Context, input CreateAttributeDefinitionInput) (*UserAttributeDefinition, error) {
|
||||
// Validate type
|
||||
if !isValidAttributeType(input.Type) {
|
||||
return nil, ErrInvalidAttributeType
|
||||
}
|
||||
|
||||
// Check if key exists
|
||||
exists, err := s.defRepo.ExistsByKey(ctx, input.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check key exists: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, ErrAttributeKeyExists
|
||||
}
|
||||
|
||||
def := &UserAttributeDefinition{
|
||||
Key: input.Key,
|
||||
Name: input.Name,
|
||||
Description: input.Description,
|
||||
Type: input.Type,
|
||||
Options: input.Options,
|
||||
Required: input.Required,
|
||||
Validation: input.Validation,
|
||||
Placeholder: input.Placeholder,
|
||||
Enabled: input.Enabled,
|
||||
}
|
||||
|
||||
if err := s.defRepo.Create(ctx, def); err != nil {
|
||||
return nil, fmt.Errorf("create definition: %w", err)
|
||||
}
|
||||
|
||||
return def, nil
|
||||
}
|
||||
|
||||
// GetDefinition retrieves a definition by ID
|
||||
func (s *UserAttributeService) GetDefinition(ctx context.Context, id int64) (*UserAttributeDefinition, error) {
|
||||
return s.defRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// ListDefinitions lists all definitions
|
||||
func (s *UserAttributeService) ListDefinitions(ctx context.Context, enabledOnly bool) ([]UserAttributeDefinition, error) {
|
||||
return s.defRepo.List(ctx, enabledOnly)
|
||||
}
|
||||
|
||||
// UpdateDefinition updates an existing definition
|
||||
func (s *UserAttributeService) UpdateDefinition(ctx context.Context, id int64, input UpdateAttributeDefinitionInput) (*UserAttributeDefinition, error) {
|
||||
def, err := s.defRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if input.Name != nil {
|
||||
def.Name = *input.Name
|
||||
}
|
||||
if input.Description != nil {
|
||||
def.Description = *input.Description
|
||||
}
|
||||
if input.Type != nil {
|
||||
if !isValidAttributeType(*input.Type) {
|
||||
return nil, ErrInvalidAttributeType
|
||||
}
|
||||
def.Type = *input.Type
|
||||
}
|
||||
if input.Options != nil {
|
||||
def.Options = *input.Options
|
||||
}
|
||||
if input.Required != nil {
|
||||
def.Required = *input.Required
|
||||
}
|
||||
if input.Validation != nil {
|
||||
def.Validation = *input.Validation
|
||||
}
|
||||
if input.Placeholder != nil {
|
||||
def.Placeholder = *input.Placeholder
|
||||
}
|
||||
if input.Enabled != nil {
|
||||
def.Enabled = *input.Enabled
|
||||
}
|
||||
|
||||
if err := s.defRepo.Update(ctx, def); err != nil {
|
||||
return nil, fmt.Errorf("update definition: %w", err)
|
||||
}
|
||||
|
||||
return def, nil
|
||||
}
|
||||
|
||||
// DeleteDefinition soft-deletes a definition and hard-deletes associated values
|
||||
func (s *UserAttributeService) DeleteDefinition(ctx context.Context, id int64) error {
|
||||
// Check if definition exists
|
||||
_, err := s.defRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// First delete all values (hard delete)
|
||||
if err := s.valueRepo.DeleteByAttributeID(ctx, id); err != nil {
|
||||
return fmt.Errorf("delete values: %w", err)
|
||||
}
|
||||
|
||||
// Then soft-delete the definition
|
||||
if err := s.defRepo.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("delete definition: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReorderDefinitions updates display order for multiple definitions
|
||||
func (s *UserAttributeService) ReorderDefinitions(ctx context.Context, orders map[int64]int) error {
|
||||
return s.defRepo.UpdateDisplayOrders(ctx, orders)
|
||||
}
|
||||
|
||||
// GetUserAttributes retrieves all attribute values for a user
|
||||
func (s *UserAttributeService) GetUserAttributes(ctx context.Context, userID int64) ([]UserAttributeValue, error) {
|
||||
return s.valueRepo.GetByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// GetBatchUserAttributes retrieves attribute values for multiple users
|
||||
// Returns a map of userID -> map of attributeID -> value
|
||||
func (s *UserAttributeService) GetBatchUserAttributes(ctx context.Context, userIDs []int64) (map[int64]map[int64]string, error) {
|
||||
values, err := s.valueRepo.GetByUserIDs(ctx, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[int64]map[int64]string)
|
||||
for _, v := range values {
|
||||
if result[v.UserID] == nil {
|
||||
result[v.UserID] = make(map[int64]string)
|
||||
}
|
||||
result[v.UserID][v.AttributeID] = v.Value
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateUserAttributes batch updates attribute values for a user
|
||||
func (s *UserAttributeService) UpdateUserAttributes(ctx context.Context, userID int64, inputs []UpdateUserAttributeInput) error {
|
||||
// Validate all values before updating
|
||||
defs, err := s.defRepo.List(ctx, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list definitions: %w", err)
|
||||
}
|
||||
|
||||
defMap := make(map[int64]*UserAttributeDefinition, len(defs))
|
||||
for i := range defs {
|
||||
defMap[defs[i].ID] = &defs[i]
|
||||
}
|
||||
|
||||
for _, input := range inputs {
|
||||
def, ok := defMap[input.AttributeID]
|
||||
if !ok {
|
||||
return ErrAttributeDefinitionNotFound
|
||||
}
|
||||
|
||||
if err := s.validateValue(def, input.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.valueRepo.UpsertBatch(ctx, userID, inputs)
|
||||
}
|
||||
|
||||
// validateValue validates a value against its definition
|
||||
func (s *UserAttributeService) validateValue(def *UserAttributeDefinition, value string) error {
|
||||
// Skip validation for empty non-required fields
|
||||
if value == "" && !def.Required {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Required check
|
||||
if def.Required && value == "" {
|
||||
return validationError(fmt.Sprintf("%s is required", def.Name))
|
||||
}
|
||||
|
||||
v := def.Validation
|
||||
|
||||
// String length validation
|
||||
if v.MinLength != nil && len(value) < *v.MinLength {
|
||||
return validationError(fmt.Sprintf("%s must be at least %d characters", def.Name, *v.MinLength))
|
||||
}
|
||||
if v.MaxLength != nil && len(value) > *v.MaxLength {
|
||||
return validationError(fmt.Sprintf("%s must be at most %d characters", def.Name, *v.MaxLength))
|
||||
}
|
||||
|
||||
// Number validation
|
||||
if def.Type == AttributeTypeNumber && value != "" {
|
||||
num, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return validationError(fmt.Sprintf("%s must be a number", def.Name))
|
||||
}
|
||||
if v.Min != nil && num < *v.Min {
|
||||
return validationError(fmt.Sprintf("%s must be at least %d", def.Name, *v.Min))
|
||||
}
|
||||
if v.Max != nil && num > *v.Max {
|
||||
return validationError(fmt.Sprintf("%s must be at most %d", def.Name, *v.Max))
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern validation
|
||||
if v.Pattern != nil && *v.Pattern != "" && value != "" {
|
||||
re, err := regexp.Compile(*v.Pattern)
|
||||
if err == nil && !re.MatchString(value) {
|
||||
msg := def.Name + " format is invalid"
|
||||
if v.Message != nil && *v.Message != "" {
|
||||
msg = *v.Message
|
||||
}
|
||||
return validationError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Select validation
|
||||
if def.Type == AttributeTypeSelect && value != "" {
|
||||
found := false
|
||||
for _, opt := range def.Options {
|
||||
if opt.Value == value {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return validationError(fmt.Sprintf("%s: invalid option", def.Name))
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-select validation (stored as JSON array)
|
||||
if def.Type == AttributeTypeMultiSelect && value != "" {
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(value), &values); err != nil {
|
||||
// Try comma-separated fallback
|
||||
values = strings.Split(value, ",")
|
||||
}
|
||||
for _, val := range values {
|
||||
val = strings.TrimSpace(val)
|
||||
found := false
|
||||
for _, opt := range def.Options {
|
||||
if opt.Value == val {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return validationError(fmt.Sprintf("%s: invalid option %s", def.Name, val))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validationError creates a validation error with a custom message
|
||||
func validationError(msg string) error {
|
||||
return infraerrors.BadRequest("ATTRIBUTE_VALIDATION_FAILED", msg)
|
||||
}
|
||||
|
||||
func isValidAttributeType(t UserAttributeType) bool {
|
||||
switch t {
|
||||
case AttributeTypeText, AttributeTypeTextarea, AttributeTypeNumber,
|
||||
AttributeTypeEmail, AttributeTypeURL, AttributeTypeDate,
|
||||
AttributeTypeSelect, AttributeTypeMultiSelect:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -125,4 +125,5 @@ var ProviderSet = wire.NewSet(
|
||||
ProvideTimingWheelService,
|
||||
ProvideDeferredService,
|
||||
ProvideAntigravityQuotaRefresher,
|
||||
NewUserAttributeService,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user