Yimaru-BackEnd/internal/services/profilefieldoptions/service.go
Yared Yemane a5acd00637 Add admin-managed field options API for configurable dropdowns.
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>
2026-05-22 09:21:36 -07:00

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
}