Store options in field_options with public /field-options and admin CRUD; validate learner profile values on update. Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
5.9 KiB
Go
211 lines
5.9 KiB
Go
package profilefieldoptions
|
|
|
|
import (
|
|
"Yimaru-Backend/internal/domain"
|
|
"Yimaru-Backend/internal/ports"
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var optionCodePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
|
|
|
|
type Service struct {
|
|
store ports.ProfileFieldOptionStore
|
|
}
|
|
|
|
func NewService(store ports.ProfileFieldOptionStore) *Service {
|
|
return &Service{store: store}
|
|
}
|
|
|
|
func normalizeOptionStatus(status *string) (string, error) {
|
|
if status == nil || strings.TrimSpace(*status) == "" {
|
|
return domain.ProfileFieldOptionStatusActive, nil
|
|
}
|
|
value := strings.ToUpper(strings.TrimSpace(*status))
|
|
switch value {
|
|
case domain.ProfileFieldOptionStatusActive, domain.ProfileFieldOptionStatusInactive:
|
|
return value, nil
|
|
default:
|
|
return "", fmt.Errorf("status must be one of %s, %s", domain.ProfileFieldOptionStatusActive, domain.ProfileFieldOptionStatusInactive)
|
|
}
|
|
}
|
|
|
|
func normalizeOptionCode(code string) (string, error) {
|
|
normalized := strings.ToUpper(strings.TrimSpace(code))
|
|
normalized = strings.ReplaceAll(normalized, " ", "_")
|
|
normalized = strings.ReplaceAll(normalized, "-", "_")
|
|
if !optionCodePattern.MatchString(normalized) {
|
|
return "", domain.ErrInvalidOptionCode
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
var fieldKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
|
|
|
func normalizeFieldKey(fieldKey string) (string, error) {
|
|
key := strings.TrimSpace(strings.ToLower(fieldKey))
|
|
if !fieldKeyPattern.MatchString(key) {
|
|
return "", domain.ErrInvalidFieldKey
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func (s *Service) CreateProfileFieldOption(ctx context.Context, input domain.CreateProfileFieldOptionInput) (domain.ProfileFieldOption, error) {
|
|
fieldKey, err := normalizeFieldKey(input.FieldKey)
|
|
if err != nil {
|
|
return domain.ProfileFieldOption{}, err
|
|
}
|
|
code, err := normalizeOptionCode(input.Code)
|
|
if err != nil {
|
|
return domain.ProfileFieldOption{}, err
|
|
}
|
|
label := strings.TrimSpace(input.Label)
|
|
if label == "" {
|
|
return domain.ProfileFieldOption{}, fmt.Errorf("label is required")
|
|
}
|
|
status, err := normalizeOptionStatus(input.Status)
|
|
if err != nil {
|
|
return domain.ProfileFieldOption{}, err
|
|
}
|
|
input.FieldKey = fieldKey
|
|
input.Code = code
|
|
input.Label = label
|
|
input.Status = &status
|
|
return s.store.CreateProfileFieldOption(ctx, input)
|
|
}
|
|
|
|
func (s *Service) UpdateProfileFieldOption(ctx context.Context, id int64, input domain.UpdateProfileFieldOptionInput) (domain.ProfileFieldOption, error) {
|
|
if id <= 0 {
|
|
return domain.ProfileFieldOption{}, fmt.Errorf("invalid profile field option id")
|
|
}
|
|
if input.Label != nil {
|
|
trimmed := strings.TrimSpace(*input.Label)
|
|
if trimmed == "" {
|
|
return domain.ProfileFieldOption{}, fmt.Errorf("label cannot be empty")
|
|
}
|
|
input.Label = &trimmed
|
|
}
|
|
if input.Status != nil {
|
|
status, err := normalizeOptionStatus(input.Status)
|
|
if err != nil {
|
|
return domain.ProfileFieldOption{}, err
|
|
}
|
|
input.Status = &status
|
|
}
|
|
return s.store.UpdateProfileFieldOption(ctx, id, input)
|
|
}
|
|
|
|
func (s *Service) GetProfileFieldOptionByID(ctx context.Context, id int64, includeInactive bool) (domain.ProfileFieldOption, error) {
|
|
if id <= 0 {
|
|
return domain.ProfileFieldOption{}, fmt.Errorf("invalid profile field option id")
|
|
}
|
|
return s.store.GetProfileFieldOptionByID(ctx, id, includeInactive)
|
|
}
|
|
|
|
func (s *Service) ListProfileFieldOptions(ctx context.Context, fieldKey *string, status *string, limit, offset int32) ([]domain.ProfileFieldOption, int64, error) {
|
|
if fieldKey != nil {
|
|
normalized, err := normalizeFieldKey(*fieldKey)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
fieldKey = &normalized
|
|
}
|
|
if status != nil {
|
|
normalized, err := normalizeOptionStatus(status)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
status = &normalized
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if limit > 500 {
|
|
limit = 500
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
return s.store.ListProfileFieldOptions(ctx, fieldKey, status, limit, offset)
|
|
}
|
|
|
|
func (s *Service) ListActiveOptionsGrouped(ctx context.Context, fieldKey *string) (domain.ProfileFieldOptionsGrouped, error) {
|
|
if fieldKey != nil {
|
|
normalized, err := normalizeFieldKey(*fieldKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fieldKey = &normalized
|
|
}
|
|
|
|
options, err := s.store.ListActiveProfileFieldOptions(ctx, fieldKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
grouped := make(domain.ProfileFieldOptionsGrouped)
|
|
for _, opt := range options {
|
|
grouped[opt.FieldKey] = append(grouped[opt.FieldKey], domain.ProfileFieldOptionItem{
|
|
Code: opt.Code,
|
|
Label: opt.Label,
|
|
})
|
|
}
|
|
return grouped, nil
|
|
}
|
|
|
|
func (s *Service) ListFieldKeys(ctx context.Context, activeOnly bool) ([]string, error) {
|
|
return s.store.ListDistinctFieldKeys(ctx, activeOnly)
|
|
}
|
|
|
|
func (s *Service) DeleteProfileFieldOption(ctx context.Context, id int64) error {
|
|
if id <= 0 {
|
|
return fmt.Errorf("invalid profile field option id")
|
|
}
|
|
return s.store.DeleteProfileFieldOption(ctx, id)
|
|
}
|
|
|
|
func (s *Service) ValidateUserProfileFieldValues(ctx context.Context, req domain.UpdateUserReq) error {
|
|
checks := []struct {
|
|
fieldKey string
|
|
value string
|
|
}{
|
|
{domain.ProfileFieldEducationLevel, req.EducationLevel},
|
|
{domain.ProfileFieldOccupation, req.Occupation},
|
|
{domain.ProfileFieldLearningGoal, req.LearningGoal},
|
|
{domain.ProfileFieldLanguageChallange, req.LanguageChallange},
|
|
{domain.ProfileFieldLanguageGoal, req.LanguageGoal},
|
|
{domain.ProfileFieldFavouriteTopic, req.FavouriteTopic},
|
|
}
|
|
|
|
for _, check := range checks {
|
|
value := strings.TrimSpace(check.value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
ok, err := s.store.IsActiveProfileFieldOption(ctx, check.fieldKey, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("invalid value for %s: %s", check.fieldKey, value)
|
|
}
|
|
}
|
|
|
|
if req.AgeGroup != nil {
|
|
age := strings.TrimSpace(string(*req.AgeGroup))
|
|
if age != "" {
|
|
ok, err := s.store.IsActiveProfileFieldOption(ctx, domain.ProfileFieldAgeGroup, age)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("invalid value for %s: %s", domain.ProfileFieldAgeGroup, age)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|