From a5acd0063747e1c75d7186cb8e55125676a2d978 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 22 May 2026 09:21:36 -0700 Subject: [PATCH] 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 --- cmd/main.go | 4 + .../000069_profile_field_options.down.sql | 1 + .../000069_profile_field_options.up.sql | 74 +++++ ...le_field_options_to_field_options.down.sql | 1 + ...file_field_options_to_field_options.up.sql | 20 ++ internal/domain/profile_field_option.go | 77 +++++ internal/ports/profile_field_option.go | 17 + internal/repository/profile_field_options.go | 214 +++++++++++++ .../services/profilefieldoptions/service.go | 210 ++++++++++++ internal/services/rbac/seeds.go | 10 + internal/services/user/service.go | 28 +- internal/services/user/user.go | 6 +- internal/web_server/app.go | 12 +- internal/web_server/handlers/field_option.go | 303 ++++++++++++++++++ internal/web_server/handlers/handlers.go | 12 +- internal/web_server/routes.go | 10 + 16 files changed, 978 insertions(+), 21 deletions(-) create mode 100644 db/migrations/000069_profile_field_options.down.sql create mode 100644 db/migrations/000069_profile_field_options.up.sql create mode 100644 db/migrations/000070_rename_profile_field_options_to_field_options.down.sql create mode 100644 db/migrations/000070_rename_profile_field_options_to_field_options.up.sql create mode 100644 internal/domain/profile_field_option.go create mode 100644 internal/ports/profile_field_option.go create mode 100644 internal/repository/profile_field_options.go create mode 100644 internal/services/profilefieldoptions/service.go create mode 100644 internal/web_server/handlers/field_option.go diff --git a/cmd/main.go b/cmd/main.go index 2d63059..eec006b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -31,6 +31,7 @@ import ( notificationservice "Yimaru-Backend/internal/services/notification" personasservice "Yimaru-Backend/internal/services/personas" practicesservice "Yimaru-Backend/internal/services/practices" + profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions" programsservice "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" ratingsservice "Yimaru-Backend/internal/services/ratings" @@ -109,6 +110,7 @@ func main() { messengerSvc := messenger.NewService(settingSvc, cfg) emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store)) + profileFieldOptionSvc := profilefieldoptions.NewService(repository.NewProfileFieldOptionStore(store)) userSvc := user.NewService( repository.NewTokenStore(store), @@ -116,6 +118,7 @@ func main() { repository.NewOTPStore(store), messengerSvc, emailTemplateSvc, + profileFieldOptionSvc, cfg, ) @@ -475,6 +478,7 @@ func main() { questionsSvc, faqSvc, emailTemplateSvc, + profileFieldOptionSvc, personasSvc, examPrepSvc, programSvc, diff --git a/db/migrations/000069_profile_field_options.down.sql b/db/migrations/000069_profile_field_options.down.sql new file mode 100644 index 0000000..c8e46ab --- /dev/null +++ b/db/migrations/000069_profile_field_options.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS field_options; diff --git a/db/migrations/000069_profile_field_options.up.sql b/db/migrations/000069_profile_field_options.up.sql new file mode 100644 index 0000000..061fac1 --- /dev/null +++ b/db/migrations/000069_profile_field_options.up.sql @@ -0,0 +1,74 @@ +CREATE TABLE IF NOT EXISTS field_options ( + id BIGSERIAL PRIMARY KEY, + field_key VARCHAR(50) NOT NULL, + code VARCHAR(50) NOT NULL, + label VARCHAR(255) NOT NULL, + display_order INT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + CONSTRAINT field_options_field_key_format CHECK (field_key ~ '^[a-z][a-z0-9_]*$'), + CONSTRAINT field_options_unique_field_code UNIQUE (field_key, code) +); + +CREATE INDEX IF NOT EXISTS idx_field_options_field_key ON field_options(field_key); +CREATE INDEX IF NOT EXISTS idx_field_options_status ON field_options(status); +CREATE INDEX IF NOT EXISTS idx_field_options_display_order ON field_options(display_order); + +INSERT INTO field_options (field_key, code, label, display_order, status) VALUES + ('education_level', 'NO_FORMAL', 'No formal education', 1, 'ACTIVE'), + ('education_level', 'PRIMARY', 'Primary school', 2, 'ACTIVE'), + ('education_level', 'SECONDARY', 'Secondary school', 3, 'ACTIVE'), + ('education_level', 'HIGH_SCHOOL', 'High school', 4, 'ACTIVE'), + ('education_level', 'VOCATIONAL', 'Vocational / technical', 5, 'ACTIVE'), + ('education_level', 'BACHELOR', 'Bachelor''s degree', 6, 'ACTIVE'), + ('education_level', 'MASTER', 'Master''s degree', 7, 'ACTIVE'), + ('education_level', 'DOCTORATE', 'Doctorate', 8, 'ACTIVE'), + ('education_level', 'OTHER', 'Other', 99, 'ACTIVE'), + + ('occupation', 'STUDENT', 'Student', 1, 'ACTIVE'), + ('occupation', 'EMPLOYED', 'Employed', 2, 'ACTIVE'), + ('occupation', 'SELF_EMPLOYED', 'Self-employed', 3, 'ACTIVE'), + ('occupation', 'UNEMPLOYED', 'Unemployed', 4, 'ACTIVE'), + ('occupation', 'HOMEMAKER', 'Homemaker', 5, 'ACTIVE'), + ('occupation', 'RETIRED', 'Retired', 6, 'ACTIVE'), + ('occupation', 'OTHER', 'Other', 99, 'ACTIVE'), + + ('age_group', 'UNDER_13', 'Under 13', 1, 'ACTIVE'), + ('age_group', '13_17', '13–17', 2, 'ACTIVE'), + ('age_group', '18_24', '18–24', 3, 'ACTIVE'), + ('age_group', '25_34', '25–34', 4, 'ACTIVE'), + ('age_group', '35_44', '35–44', 5, 'ACTIVE'), + ('age_group', '45_54', '45–54', 6, 'ACTIVE'), + ('age_group', '55_PLUS', '55+', 7, 'ACTIVE'), + + ('learning_goal', 'EVERYDAY_CONVERSATION', 'Everyday conversation', 1, 'ACTIVE'), + ('learning_goal', 'WORK_CAREER', 'Work and career', 2, 'ACTIVE'), + ('learning_goal', 'ACADEMIC_STUDY', 'Academic study', 3, 'ACTIVE'), + ('learning_goal', 'TRAVEL', 'Travel', 4, 'ACTIVE'), + ('learning_goal', 'EXAM_PREP', 'Exam preparation', 5, 'ACTIVE'), + ('learning_goal', 'PERSONAL_GROWTH', 'Personal growth', 6, 'ACTIVE'), + ('learning_goal', 'OTHER', 'Other', 99, 'ACTIVE'), + + ('language_challange', 'PRONUNCIATION', 'Pronunciation', 1, 'ACTIVE'), + ('language_challange', 'GRAMMAR', 'Grammar', 2, 'ACTIVE'), + ('language_challange', 'VOCABULARY', 'Vocabulary', 3, 'ACTIVE'), + ('language_challange', 'LISTENING', 'Listening', 4, 'ACTIVE'), + ('language_challange', 'SPEAKING', 'Speaking confidence', 5, 'ACTIVE'), + ('language_challange', 'WRITING', 'Writing', 6, 'ACTIVE'), + ('language_challange', 'READING', 'Reading', 7, 'ACTIVE'), + ('language_challange', 'OTHER', 'Other', 99, 'ACTIVE'), + + ('language_goal', 'BASIC', 'Basic communication', 1, 'ACTIVE'), + ('language_goal', 'CONVERSATIONAL', 'Conversational fluency', 2, 'ACTIVE'), + ('language_goal', 'PROFESSIONAL', 'Professional proficiency', 3, 'ACTIVE'), + ('language_goal', 'ACADEMIC', 'Academic proficiency', 4, 'ACTIVE'), + ('language_goal', 'NATIVE_LIKE', 'Near-native fluency', 5, 'ACTIVE'), + + ('favourite_topic', 'BUSINESS', 'Business', 1, 'ACTIVE'), + ('favourite_topic', 'TECHNOLOGY', 'Technology', 2, 'ACTIVE'), + ('favourite_topic', 'HEALTH', 'Health', 3, 'ACTIVE'), + ('favourite_topic', 'CULTURE', 'Culture', 4, 'ACTIVE'), + ('favourite_topic', 'TRAVEL', 'Travel', 5, 'ACTIVE'), + ('favourite_topic', 'ENTERTAINMENT', 'Entertainment', 6, 'ACTIVE'), + ('favourite_topic', 'OTHER', 'Other', 99, 'ACTIVE'); diff --git a/db/migrations/000070_rename_profile_field_options_to_field_options.down.sql b/db/migrations/000070_rename_profile_field_options_to_field_options.down.sql new file mode 100644 index 0000000..22a8371 --- /dev/null +++ b/db/migrations/000070_rename_profile_field_options_to_field_options.down.sql @@ -0,0 +1 @@ +-- No-op: keep field_options table name on rollback of 070 alone. diff --git a/db/migrations/000070_rename_profile_field_options_to_field_options.up.sql b/db/migrations/000070_rename_profile_field_options_to_field_options.up.sql new file mode 100644 index 0000000..d91edd5 --- /dev/null +++ b/db/migrations/000070_rename_profile_field_options_to_field_options.up.sql @@ -0,0 +1,20 @@ +-- For databases that already applied 000069 with profile_field_options table name. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'profile_field_options' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'field_options' + ) THEN + ALTER TABLE profile_field_options RENAME TO field_options; + ALTER TABLE field_options RENAME CONSTRAINT profile_field_options_field_key_check TO field_options_field_key_check_old; + ALTER TABLE field_options DROP CONSTRAINT IF EXISTS field_options_field_key_check_old; + ALTER TABLE field_options RENAME CONSTRAINT profile_field_options_unique_field_code TO field_options_unique_field_code; + ALTER INDEX IF EXISTS idx_profile_field_options_field_key RENAME TO idx_field_options_field_key; + ALTER INDEX IF EXISTS idx_profile_field_options_status RENAME TO idx_field_options_status; + ALTER INDEX IF EXISTS idx_profile_field_options_display_order RENAME TO idx_field_options_display_order; + ALTER TABLE field_options ADD CONSTRAINT field_options_field_key_format CHECK (field_key ~ '^[a-z][a-z0-9_]*$'); + END IF; +END $$; diff --git a/internal/domain/profile_field_option.go b/internal/domain/profile_field_option.go new file mode 100644 index 0000000..79219dd --- /dev/null +++ b/internal/domain/profile_field_option.go @@ -0,0 +1,77 @@ +package domain + +import ( + "errors" + "time" +) + +const ( + ProfileFieldOptionStatusActive = "ACTIVE" + ProfileFieldOptionStatusInactive = "INACTIVE" +) + +var ( + ErrInvalidFieldKey = errors.New("invalid field key") + ErrInvalidOptionCode = errors.New("invalid field option code") + ErrFieldOptionNotFound = errors.New("field option not found") +) + +// Deprecated aliases for internal references during transition. +var ( + ErrInvalidProfileFieldKey = ErrInvalidFieldKey + ErrInvalidProfileFieldOptionCode = ErrInvalidOptionCode + ErrProfileFieldOptionNotFound = ErrFieldOptionNotFound +) + +const ( + ProfileFieldEducationLevel = "education_level" + ProfileFieldOccupation = "occupation" + ProfileFieldAgeGroup = "age_group" + ProfileFieldLearningGoal = "learning_goal" + ProfileFieldLanguageChallange = "language_challange" + ProfileFieldLanguageGoal = "language_goal" + ProfileFieldFavouriteTopic = "favourite_topic" +) + +// User profile columns validated against field_options on PUT /user (when non-empty). +var UserProfileFieldsWithOptions = []string{ + ProfileFieldEducationLevel, + ProfileFieldOccupation, + ProfileFieldAgeGroup, + ProfileFieldLearningGoal, + ProfileFieldLanguageChallange, + ProfileFieldLanguageGoal, + ProfileFieldFavouriteTopic, +} + +type ProfileFieldOption struct { + ID int64 + FieldKey string + Code string + Label string + DisplayOrder int32 + Status string + CreatedAt time.Time + UpdatedAt *time.Time +} + +type CreateProfileFieldOptionInput struct { + FieldKey string + Code string + Label string + DisplayOrder *int32 + Status *string +} + +type UpdateProfileFieldOptionInput struct { + Label *string + DisplayOrder *int32 + Status *string +} + +type ProfileFieldOptionsGrouped map[string][]ProfileFieldOptionItem + +type ProfileFieldOptionItem struct { + Code string `json:"code"` + Label string `json:"label"` +} diff --git a/internal/ports/profile_field_option.go b/internal/ports/profile_field_option.go new file mode 100644 index 0000000..dbef67d --- /dev/null +++ b/internal/ports/profile_field_option.go @@ -0,0 +1,17 @@ +package ports + +import ( + "Yimaru-Backend/internal/domain" + "context" +) + +type ProfileFieldOptionStore interface { + CreateProfileFieldOption(ctx context.Context, input domain.CreateProfileFieldOptionInput) (domain.ProfileFieldOption, error) + UpdateProfileFieldOption(ctx context.Context, id int64, input domain.UpdateProfileFieldOptionInput) (domain.ProfileFieldOption, error) + GetProfileFieldOptionByID(ctx context.Context, id int64, includeInactive bool) (domain.ProfileFieldOption, error) + ListProfileFieldOptions(ctx context.Context, fieldKey *string, status *string, limit, offset int32) ([]domain.ProfileFieldOption, int64, error) + ListActiveProfileFieldOptions(ctx context.Context, fieldKey *string) ([]domain.ProfileFieldOption, error) + IsActiveProfileFieldOption(ctx context.Context, fieldKey, code string) (bool, error) + ListDistinctFieldKeys(ctx context.Context, activeOnly bool) ([]string, error) + DeleteProfileFieldOption(ctx context.Context, id int64) error +} diff --git a/internal/repository/profile_field_options.go b/internal/repository/profile_field_options.go new file mode 100644 index 0000000..d40960b --- /dev/null +++ b/internal/repository/profile_field_options.go @@ -0,0 +1,214 @@ +package repository + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func NewProfileFieldOptionStore(s *Store) ports.ProfileFieldOptionStore { return s } + +func profileFieldOptionToDomain( + id int64, + fieldKey, code, label string, + displayOrder int32, + status string, + createdAt pgtype.Timestamptz, + updatedAt pgtype.Timestamptz, +) domain.ProfileFieldOption { + return domain.ProfileFieldOption{ + ID: id, + FieldKey: fieldKey, + Code: code, + Label: label, + DisplayOrder: displayOrder, + Status: status, + CreatedAt: createdAt.Time, + UpdatedAt: timePtr(updatedAt), + } +} + +func (s *Store) CreateProfileFieldOption(ctx context.Context, input domain.CreateProfileFieldOptionInput) (domain.ProfileFieldOption, error) { + displayOrder := int32(0) + if input.DisplayOrder != nil { + displayOrder = *input.DisplayOrder + } + status := domain.ProfileFieldOptionStatusActive + if input.Status != nil { + status = *input.Status + } + + row := s.conn.QueryRow(ctx, ` + INSERT INTO field_options (field_key, code, label, display_order, status) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, field_key, code, label, display_order, status, created_at, updated_at + `, input.FieldKey, input.Code, input.Label, displayOrder, status) + + var ( + id int64 + fieldKey string + code string + label string + orderVal int32 + optionStatus string + createdAt pgtype.Timestamptz + updatedAt pgtype.Timestamptz + ) + if err := row.Scan(&id, &fieldKey, &code, &label, &orderVal, &optionStatus, &createdAt, &updatedAt); err != nil { + return domain.ProfileFieldOption{}, err + } + return profileFieldOptionToDomain(id, fieldKey, code, label, orderVal, optionStatus, createdAt, updatedAt), nil +} + +func (s *Store) UpdateProfileFieldOption(ctx context.Context, id int64, input domain.UpdateProfileFieldOptionInput) (domain.ProfileFieldOption, error) { + row := s.conn.QueryRow(ctx, ` + UPDATE field_options + SET label = COALESCE($2, label), + display_order = COALESCE($3, display_order), + status = COALESCE($4, status), + updated_at = NOW() + WHERE id = $1 + RETURNING id, field_key, code, label, display_order, status, created_at, updated_at + `, id, input.Label, input.DisplayOrder, input.Status) + + var ( + optionID int64 + fieldKey string + code string + label string + orderVal int32 + optionStatus string + createdAt pgtype.Timestamptz + updatedAt pgtype.Timestamptz + ) + if err := row.Scan(&optionID, &fieldKey, &code, &label, &orderVal, &optionStatus, &createdAt, &updatedAt); err != nil { + return domain.ProfileFieldOption{}, err + } + return profileFieldOptionToDomain(optionID, fieldKey, code, label, orderVal, optionStatus, createdAt, updatedAt), nil +} + +func (s *Store) GetProfileFieldOptionByID(ctx context.Context, id int64, includeInactive bool) (domain.ProfileFieldOption, error) { + row := s.conn.QueryRow(ctx, ` + SELECT id, field_key, code, label, display_order, status, created_at, updated_at + FROM field_options + WHERE id = $1 + AND ($2::boolean = TRUE OR status = 'ACTIVE') + `, id, includeInactive) + + var ( + optionID int64 + fieldKey string + code string + label string + orderVal int32 + optionStatus string + createdAt pgtype.Timestamptz + updatedAt pgtype.Timestamptz + ) + if err := row.Scan(&optionID, &fieldKey, &code, &label, &orderVal, &optionStatus, &createdAt, &updatedAt); err != nil { + return domain.ProfileFieldOption{}, err + } + return profileFieldOptionToDomain(optionID, fieldKey, code, label, orderVal, optionStatus, createdAt, updatedAt), nil +} + +func (s *Store) ListProfileFieldOptions(ctx context.Context, fieldKey *string, status *string, limit, offset int32) ([]domain.ProfileFieldOption, int64, error) { + rows, err := s.conn.Query(ctx, ` + SELECT id, field_key, code, label, display_order, status, created_at, updated_at + FROM field_options + WHERE ($1::text IS NULL OR field_key = $1) + AND ($2::text IS NULL OR status = $2) + ORDER BY field_key ASC, display_order ASC, id ASC + LIMIT $3 OFFSET $4 + `, toPgText(fieldKey), toPgText(status), limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + options := make([]domain.ProfileFieldOption, 0) + for rows.Next() { + var ( + optionID int64 + fk string + code string + label string + orderVal int32 + optionStatus string + createdAt pgtype.Timestamptz + updatedAt pgtype.Timestamptz + ) + if err := rows.Scan(&optionID, &fk, &code, &label, &orderVal, &optionStatus, &createdAt, &updatedAt); err != nil { + return nil, 0, err + } + options = append(options, profileFieldOptionToDomain(optionID, fk, code, label, orderVal, optionStatus, createdAt, updatedAt)) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + var totalCount int64 + if err := s.conn.QueryRow(ctx, ` + SELECT COUNT(*) + FROM field_options + WHERE ($1::text IS NULL OR field_key = $1) + AND ($2::text IS NULL OR status = $2) + `, toPgText(fieldKey), toPgText(status)).Scan(&totalCount); err != nil { + return nil, 0, err + } + + return options, totalCount, nil +} + +func (s *Store) ListActiveProfileFieldOptions(ctx context.Context, fieldKey *string) ([]domain.ProfileFieldOption, error) { + active := domain.ProfileFieldOptionStatusActive + options, _, err := s.ListProfileFieldOptions(ctx, fieldKey, &active, 500, 0) + return options, err +} + +func (s *Store) IsActiveProfileFieldOption(ctx context.Context, fieldKey, code string) (bool, error) { + var exists bool + err := s.conn.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM field_options + WHERE field_key = $1 AND code = $2 AND status = 'ACTIVE' + ) + `, fieldKey, code).Scan(&exists) + return exists, err +} + +func (s *Store) ListDistinctFieldKeys(ctx context.Context, activeOnly bool) ([]string, error) { + rows, err := s.conn.Query(ctx, ` + SELECT DISTINCT field_key + FROM field_options + WHERE ($1::boolean = FALSE OR status = 'ACTIVE') + ORDER BY field_key ASC + `, activeOnly) + if err != nil { + return nil, err + } + defer rows.Close() + + keys := make([]string, 0) + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + return nil, err + } + keys = append(keys, key) + } + return keys, rows.Err() +} + +func (s *Store) DeleteProfileFieldOption(ctx context.Context, id int64) error { + cmd, err := s.conn.Exec(ctx, `DELETE FROM field_options WHERE id = $1`, id) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} diff --git a/internal/services/profilefieldoptions/service.go b/internal/services/profilefieldoptions/service.go new file mode 100644 index 0000000..51678f9 --- /dev/null +++ b/internal/services/profilefieldoptions/service.go @@ -0,0 +1,210 @@ +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 +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 446e1ea..9e7a82b 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -254,6 +254,13 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "email_templates.delete", Name: "Delete Email Template", Description: "Delete a custom email template", GroupName: "Email Templates"}, {Key: "email_templates.preview", Name: "Preview Email Template", Description: "Preview a rendered email template", GroupName: "Email Templates"}, + // Field options (dropdown config: profile, countries, etc.) + {Key: "field_options.create", Name: "Create Field Option", Description: "Create a configurable dropdown option", GroupName: "Field Options"}, + {Key: "field_options.list", Name: "List Field Options", Description: "List field options for admin management", GroupName: "Field Options"}, + {Key: "field_options.get", Name: "Get Field Option", Description: "Get field option by ID", GroupName: "Field Options"}, + {Key: "field_options.update", Name: "Update Field Option", Description: "Update a field option", GroupName: "Field Options"}, + {Key: "field_options.delete", Name: "Delete Field Option", Description: "Delete a field option", GroupName: "Field Options"}, + // Analytics {Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"}, @@ -463,6 +470,9 @@ var DefaultRolePermissions = map[string][]string{ // Email templates "email_templates.create", "email_templates.list", "email_templates.get", "email_templates.update", "email_templates.delete", "email_templates.preview", + // Field options + "field_options.create", "field_options.list", "field_options.get", "field_options.update", "field_options.delete", + // Analytics (previously OnlyAdminAndAbove) "analytics.dashboard", diff --git a/internal/services/user/service.go b/internal/services/user/service.go index 309324e..69ea66b 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -5,6 +5,7 @@ import ( "Yimaru-Backend/internal/ports" emailtemplates "Yimaru-Backend/internal/services/emailtemplates" "Yimaru-Backend/internal/services/messenger" + profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions" "time" ) @@ -13,12 +14,13 @@ const ( ) type Service struct { - tokenStore ports.TokenStore - userStore ports.UserStore - otpStore ports.OtpStore - messengerSvc *messenger.Service - emailTemplateSvc *emailtemplates.Service - config *config.Config + tokenStore ports.TokenStore + userStore ports.UserStore + otpStore ports.OtpStore + messengerSvc *messenger.Service + emailTemplateSvc *emailtemplates.Service + profileFieldOptionSvc *profilefieldoptions.Service + config *config.Config } func NewService( @@ -27,14 +29,16 @@ func NewService( otpStore ports.OtpStore, messengerSvc *messenger.Service, emailTemplateSvc *emailtemplates.Service, + profileFieldOptionSvc *profilefieldoptions.Service, cfg *config.Config, ) *Service { return &Service{ - tokenStore: tokenStore, - userStore: userStore, - otpStore: otpStore, - messengerSvc: messengerSvc, - emailTemplateSvc: emailTemplateSvc, - config: cfg, + tokenStore: tokenStore, + userStore: userStore, + otpStore: otpStore, + messengerSvc: messengerSvc, + emailTemplateSvc: emailTemplateSvc, + profileFieldOptionSvc: profileFieldOptionSvc, + config: cfg, } } diff --git a/internal/services/user/user.go b/internal/services/user/user.go index ac46277..7674621 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -35,7 +35,11 @@ func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString stri } func (s *Service) UpdateUser(ctx context.Context, req domain.UpdateUserReq) error { - // Update user in the store + if s.profileFieldOptionSvc != nil { + if err := s.profileFieldOptionSvc.ValidateUserProfileFieldValues(ctx, req); err != nil { + return err + } + } return s.userStore.UpdateUser(ctx, req) } diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 13d336a..29f935f 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -21,6 +21,7 @@ import ( notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/personas" "Yimaru-Backend/internal/services/practices" + profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions" "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" ratingsservice "Yimaru-Backend/internal/services/ratings" @@ -50,8 +51,9 @@ import ( type App struct { assessmentSvc *assessment.Service questionsSvc *questions.Service - faqSvc *faqs.Service - emailTemplateSvc *emailtemplates.Service + faqSvc *faqs.Service + emailTemplateSvc *emailtemplates.Service + profileFieldOptionSvc *profilefieldoptions.Service personaSvc *personas.Service examPrepSvc *examprep.Service programSvc *programs.Service @@ -94,6 +96,7 @@ func NewApp( questionsSvc *questions.Service, faqSvc *faqs.Service, emailTemplateSvc *emailtemplates.Service, + profileFieldOptionSvc *profilefieldoptions.Service, personaSvc *personas.Service, examPrepSvc *examprep.Service, programSvc *programs.Service, @@ -146,8 +149,9 @@ func NewApp( s := &App{ assessmentSvc: assessmentSvc, questionsSvc: questionsSvc, - faqSvc: faqSvc, - emailTemplateSvc: emailTemplateSvc, + faqSvc: faqSvc, + emailTemplateSvc: emailTemplateSvc, + profileFieldOptionSvc: profileFieldOptionSvc, personaSvc: personaSvc, examPrepSvc: examPrepSvc, programSvc: programSvc, diff --git a/internal/web_server/handlers/field_option.go b/internal/web_server/handlers/field_option.go new file mode 100644 index 0000000..e201e40 --- /dev/null +++ b/internal/web_server/handlers/field_option.go @@ -0,0 +1,303 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "errors" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type createFieldOptionReq struct { + FieldKey string `json:"field_key" validate:"required"` + Code string `json:"code" validate:"required"` + Label string `json:"label" validate:"required"` + DisplayOrder *int32 `json:"display_order"` + Status *string `json:"status"` +} + +type updateFieldOptionReq struct { + Label *string `json:"label"` + DisplayOrder *int32 `json:"display_order"` + Status *string `json:"status"` +} + +type fieldOptionRes struct { + ID int64 `json:"id"` + FieldKey string `json:"field_key"` + Code string `json:"code"` + Label string `json:"label"` + DisplayOrder int32 `json:"display_order"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt *string `json:"updated_at,omitempty"` +} + +type listFieldOptionsRes struct { + Options []fieldOptionRes `json:"options"` + TotalCount int64 `json:"total_count"` +} + +func mapFieldOptionToRes(o domain.ProfileFieldOption) fieldOptionRes { + var updatedAt *string + if o.UpdatedAt != nil { + v := o.UpdatedAt.Format("2006-01-02T15:04:05Z07:00") + updatedAt = &v + } + return fieldOptionRes{ + ID: o.ID, + FieldKey: o.FieldKey, + Code: o.Code, + Label: o.Label, + DisplayOrder: o.DisplayOrder, + Status: o.Status, + CreatedAt: o.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: updatedAt, + } +} + +// ListPublicFieldOptions godoc +// @Summary List field options for client dropdowns +// @Description Returns active options grouped by field_key (e.g. education_level, country) +// @Tags field-options +// @Produce json +// @Param field_key query string false "Filter by field key" +// @Success 200 {object} domain.Response +// @Router /api/v1/field-options [get] +func (h *Handler) ListPublicFieldOptions(c *fiber.Ctx) error { + var fieldKeyPtr *string + if raw := strings.TrimSpace(c.Query("field_key")); raw != "" { + fieldKeyPtr = &raw + } + + grouped, err := h.profileFieldOptionSvc.ListActiveOptionsGrouped(c.Context(), fieldKeyPtr) + if err != nil { + return fieldOptionError(c, err, "Failed to list field options") + } + + return c.JSON(domain.Response{ + Message: "Field options retrieved successfully", + Data: grouped, + Success: true, + }) +} + +// ListFieldKeys godoc +// @Summary List distinct field keys +// @Description Returns field_key values that have options (e.g. education_level, country) +// @Tags field-options +// @Produce json +// @Param active_only query bool false "If true, only keys with active options" +// @Success 200 {object} domain.Response +// @Router /api/v1/field-options/fields [get] +func (h *Handler) ListFieldKeys(c *fiber.Ctx) error { + activeOnly := c.Query("active_only", "true") == "true" + keys, err := h.profileFieldOptionSvc.ListFieldKeys(c.Context(), activeOnly) + if err != nil { + return fieldOptionError(c, err, "Failed to list field keys") + } + + return c.JSON(domain.Response{ + Message: "Field keys retrieved successfully", + Data: fiber.Map{ + "fields": keys, + }, + Success: true, + }) +} + +// ListFieldOptionsAdmin godoc +// @Summary List field options (admin) +// @Tags field-options +// @Produce json +// @Param field_key query string false "Filter by field key" +// @Param status query string false "ACTIVE or INACTIVE" +// @Param limit query int false "Limit" +// @Param offset query int false "Offset" +// @Success 200 {object} domain.Response +// @Router /api/v1/admin/field-options [get] +func (h *Handler) ListFieldOptionsAdmin(c *fiber.Ctx) error { + limit, _ := strconv.Atoi(c.Query("limit", "50")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + + var fieldKeyPtr, statusPtr *string + if raw := strings.TrimSpace(c.Query("field_key")); raw != "" { + fieldKeyPtr = &raw + } + if raw := strings.TrimSpace(c.Query("status")); raw != "" { + statusPtr = &raw + } + + options, total, err := h.profileFieldOptionSvc.ListProfileFieldOptions(c.Context(), fieldKeyPtr, statusPtr, int32(limit), int32(offset)) + if err != nil { + return fieldOptionError(c, err, "Failed to list field options") + } + + out := make([]fieldOptionRes, 0, len(options)) + for _, o := range options { + out = append(out, mapFieldOptionToRes(o)) + } + + return c.JSON(domain.Response{ + Message: "Field options retrieved successfully", + Data: listFieldOptionsRes{ + Options: out, + TotalCount: total, + }, + Success: true, + }) +} + +// GetFieldOptionByIDAdmin godoc +// @Summary Get field option by ID (admin) +// @Tags field-options +// @Produce json +// @Param id path int true "Option ID" +// @Success 200 {object} domain.Response +// @Router /api/v1/admin/field-options/{id} [get] +func (h *Handler) GetFieldOptionByIDAdmin(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil || id <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid option ID", + }) + } + + option, err := h.profileFieldOptionSvc.GetProfileFieldOptionByID(c.Context(), id, true) + if err != nil { + return fieldOptionError(c, err, "Failed to get field option") + } + + return c.JSON(domain.Response{ + Message: "Field option retrieved successfully", + Data: mapFieldOptionToRes(option), + Success: true, + }) +} + +// CreateFieldOption godoc +// @Summary Create field option (admin) +// @Tags field-options +// @Accept json +// @Produce json +// @Param body body createFieldOptionReq true "Create option" +// @Success 201 {object} domain.Response +// @Router /api/v1/admin/field-options [post] +func (h *Handler) CreateFieldOption(c *fiber.Ctx) error { + var req createFieldOptionReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: firstValidationError(valErrs), + }) + } + + option, err := h.profileFieldOptionSvc.CreateProfileFieldOption(c.Context(), domain.CreateProfileFieldOptionInput{ + FieldKey: req.FieldKey, + Code: req.Code, + Label: req.Label, + DisplayOrder: req.DisplayOrder, + Status: req.Status, + }) + if err != nil { + return fieldOptionError(c, err, "Failed to create field option") + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Field option created successfully", + Data: mapFieldOptionToRes(option), + Success: true, + }) +} + +// UpdateFieldOption godoc +// @Summary Update field option (admin) +// @Tags field-options +// @Accept json +// @Produce json +// @Param id path int true "Option ID" +// @Param body body updateFieldOptionReq true "Update option" +// @Success 200 {object} domain.Response +// @Router /api/v1/admin/field-options/{id} [put] +func (h *Handler) UpdateFieldOption(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil || id <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid option ID", + }) + } + + var req updateFieldOptionReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + option, err := h.profileFieldOptionSvc.UpdateProfileFieldOption(c.Context(), id, domain.UpdateProfileFieldOptionInput{ + Label: req.Label, + DisplayOrder: req.DisplayOrder, + Status: req.Status, + }) + if err != nil { + return fieldOptionError(c, err, "Failed to update field option") + } + + return c.JSON(domain.Response{ + Message: "Field option updated successfully", + Data: mapFieldOptionToRes(option), + Success: true, + }) +} + +// DeleteFieldOption godoc +// @Summary Delete field option (admin) +// @Tags field-options +// @Produce json +// @Param id path int true "Option ID" +// @Success 200 {object} domain.Response +// @Router /api/v1/admin/field-options/{id} [delete] +func (h *Handler) DeleteFieldOption(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil || id <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid option ID", + }) + } + + if err := h.profileFieldOptionSvc.DeleteProfileFieldOption(c.Context(), id); err != nil { + return fieldOptionError(c, err, "Failed to delete field option") + } + + return c.JSON(domain.Response{ + Message: "Field option deleted successfully", + Success: true, + }) +} + +func fieldOptionError(c *fiber.Ctx, err error, message string) error { + switch { + case errors.Is(err, domain.ErrInvalidFieldKey), + errors.Is(err, domain.ErrInvalidOptionCode): + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: err.Error()}) + case errors.Is(err, pgx.ErrNoRows): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: message, Error: "Field option not found"}) + default: + if strings.Contains(err.Error(), "invalid value for") { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: err.Error()}) + } + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "An option with this field_key and code already exists"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: message, Error: err.Error()}) + } +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index cacf016..f6f9164 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -24,6 +24,7 @@ import ( notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/personas" "Yimaru-Backend/internal/services/practices" + profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions" "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" ratingsservice "Yimaru-Backend/internal/services/ratings" @@ -49,8 +50,9 @@ import ( type Handler struct { assessmentSvc *assessment.Service questionsSvc *questions.Service - faqSvc *faqs.Service - emailTemplateSvc *emailtemplates.Service + faqSvc *faqs.Service + emailTemplateSvc *emailtemplates.Service + profileFieldOptionSvc *profilefieldoptions.Service personaSvc *personas.Service examPrepSvc *examprep.Service programSvc *programs.Service @@ -89,6 +91,7 @@ func New( questionsSvc *questions.Service, faqSvc *faqs.Service, emailTemplateSvc *emailtemplates.Service, + profileFieldOptionSvc *profilefieldoptions.Service, personaSvc *personas.Service, examPrepSvc *examprep.Service, programSvc *programs.Service, @@ -124,8 +127,9 @@ func New( return &Handler{ assessmentSvc: assessmentSvc, questionsSvc: questionsSvc, - faqSvc: faqSvc, - emailTemplateSvc: emailTemplateSvc, + faqSvc: faqSvc, + emailTemplateSvc: emailTemplateSvc, + profileFieldOptionSvc: profileFieldOptionSvc, personaSvc: personaSvc, examPrepSvc: examPrepSvc, programSvc: programSvc, diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index d424379..4831882 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -17,6 +17,7 @@ func (a *App) initAppRoutes() { a.questionsSvc, a.faqSvc, a.emailTemplateSvc, + a.profileFieldOptionSvc, a.personaSvc, a.examPrepSvc, a.programSvc, @@ -207,6 +208,15 @@ func (a *App) initAppRoutes() { groupV1.Put("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.update"), h.UpdateEmailTemplate) groupV1.Delete("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.delete"), h.DeleteEmailTemplate) + // Field options (configurable dropdown values: profile fields, countries, etc.) + groupV1.Get("/field-options", h.ListPublicFieldOptions) + groupV1.Get("/field-options/fields", h.ListFieldKeys) + groupV1.Get("/admin/field-options", a.authMiddleware, a.RequirePermission("field_options.list"), h.ListFieldOptionsAdmin) + groupV1.Get("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.get"), h.GetFieldOptionByIDAdmin) + groupV1.Post("/admin/field-options", a.authMiddleware, a.RequirePermission("field_options.create"), h.CreateFieldOption) + groupV1.Put("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.update"), h.UpdateFieldOption) + groupV1.Delete("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.delete"), h.DeleteFieldOption) + // Question Sets groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet) groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)