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>
This commit is contained in:
Yared Yemane 2026-05-22 09:21:36 -07:00
parent 176f78515d
commit a5acd00637
16 changed files with 978 additions and 21 deletions

View File

@ -31,6 +31,7 @@ import (
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
personasservice "Yimaru-Backend/internal/services/personas" personasservice "Yimaru-Backend/internal/services/personas"
practicesservice "Yimaru-Backend/internal/services/practices" practicesservice "Yimaru-Backend/internal/services/practices"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
programsservice "Yimaru-Backend/internal/services/programs" programsservice "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
@ -109,6 +110,7 @@ func main() {
messengerSvc := messenger.NewService(settingSvc, cfg) messengerSvc := messenger.NewService(settingSvc, cfg)
emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store)) emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store))
profileFieldOptionSvc := profilefieldoptions.NewService(repository.NewProfileFieldOptionStore(store))
userSvc := user.NewService( userSvc := user.NewService(
repository.NewTokenStore(store), repository.NewTokenStore(store),
@ -116,6 +118,7 @@ func main() {
repository.NewOTPStore(store), repository.NewOTPStore(store),
messengerSvc, messengerSvc,
emailTemplateSvc, emailTemplateSvc,
profileFieldOptionSvc,
cfg, cfg,
) )
@ -475,6 +478,7 @@ func main() {
questionsSvc, questionsSvc,
faqSvc, faqSvc,
emailTemplateSvc, emailTemplateSvc,
profileFieldOptionSvc,
personasSvc, personasSvc,
examPrepSvc, examPrepSvc,
programSvc, programSvc,

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS field_options;

View File

@ -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', '1317', 2, 'ACTIVE'),
('age_group', '18_24', '1824', 3, 'ACTIVE'),
('age_group', '25_34', '2534', 4, 'ACTIVE'),
('age_group', '35_44', '3544', 5, 'ACTIVE'),
('age_group', '45_54', '4554', 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');

View File

@ -0,0 +1 @@
-- No-op: keep field_options table name on rollback of 070 alone.

View File

@ -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 $$;

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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.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"}, {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 // Analytics
{Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "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
"email_templates.create", "email_templates.list", "email_templates.get", "email_templates.update", "email_templates.delete", "email_templates.preview", "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 (previously OnlyAdminAndAbove)
"analytics.dashboard", "analytics.dashboard",

View File

@ -5,6 +5,7 @@ import (
"Yimaru-Backend/internal/ports" "Yimaru-Backend/internal/ports"
emailtemplates "Yimaru-Backend/internal/services/emailtemplates" emailtemplates "Yimaru-Backend/internal/services/emailtemplates"
"Yimaru-Backend/internal/services/messenger" "Yimaru-Backend/internal/services/messenger"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
"time" "time"
) )
@ -18,6 +19,7 @@ type Service struct {
otpStore ports.OtpStore otpStore ports.OtpStore
messengerSvc *messenger.Service messengerSvc *messenger.Service
emailTemplateSvc *emailtemplates.Service emailTemplateSvc *emailtemplates.Service
profileFieldOptionSvc *profilefieldoptions.Service
config *config.Config config *config.Config
} }
@ -27,6 +29,7 @@ func NewService(
otpStore ports.OtpStore, otpStore ports.OtpStore,
messengerSvc *messenger.Service, messengerSvc *messenger.Service,
emailTemplateSvc *emailtemplates.Service, emailTemplateSvc *emailtemplates.Service,
profileFieldOptionSvc *profilefieldoptions.Service,
cfg *config.Config, cfg *config.Config,
) *Service { ) *Service {
return &Service{ return &Service{
@ -35,6 +38,7 @@ func NewService(
otpStore: otpStore, otpStore: otpStore,
messengerSvc: messengerSvc, messengerSvc: messengerSvc,
emailTemplateSvc: emailTemplateSvc, emailTemplateSvc: emailTemplateSvc,
profileFieldOptionSvc: profileFieldOptionSvc,
config: cfg, config: cfg,
} }
} }

View File

@ -35,7 +35,11 @@ func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString stri
} }
func (s *Service) UpdateUser(ctx context.Context, req domain.UpdateUserReq) error { 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) return s.userStore.UpdateUser(ctx, req)
} }

View File

@ -21,6 +21,7 @@ import (
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/personas" "Yimaru-Backend/internal/services/personas"
"Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/practices"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
"Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
@ -52,6 +53,7 @@ type App struct {
questionsSvc *questions.Service questionsSvc *questions.Service
faqSvc *faqs.Service faqSvc *faqs.Service
emailTemplateSvc *emailtemplates.Service emailTemplateSvc *emailtemplates.Service
profileFieldOptionSvc *profilefieldoptions.Service
personaSvc *personas.Service personaSvc *personas.Service
examPrepSvc *examprep.Service examPrepSvc *examprep.Service
programSvc *programs.Service programSvc *programs.Service
@ -94,6 +96,7 @@ func NewApp(
questionsSvc *questions.Service, questionsSvc *questions.Service,
faqSvc *faqs.Service, faqSvc *faqs.Service,
emailTemplateSvc *emailtemplates.Service, emailTemplateSvc *emailtemplates.Service,
profileFieldOptionSvc *profilefieldoptions.Service,
personaSvc *personas.Service, personaSvc *personas.Service,
examPrepSvc *examprep.Service, examPrepSvc *examprep.Service,
programSvc *programs.Service, programSvc *programs.Service,
@ -148,6 +151,7 @@ func NewApp(
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
faqSvc: faqSvc, faqSvc: faqSvc,
emailTemplateSvc: emailTemplateSvc, emailTemplateSvc: emailTemplateSvc,
profileFieldOptionSvc: profileFieldOptionSvc,
personaSvc: personaSvc, personaSvc: personaSvc,
examPrepSvc: examPrepSvc, examPrepSvc: examPrepSvc,
programSvc: programSvc, programSvc: programSvc,

View File

@ -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()})
}
}

View File

@ -24,6 +24,7 @@ import (
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/personas" "Yimaru-Backend/internal/services/personas"
"Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/practices"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
"Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
@ -51,6 +52,7 @@ type Handler struct {
questionsSvc *questions.Service questionsSvc *questions.Service
faqSvc *faqs.Service faqSvc *faqs.Service
emailTemplateSvc *emailtemplates.Service emailTemplateSvc *emailtemplates.Service
profileFieldOptionSvc *profilefieldoptions.Service
personaSvc *personas.Service personaSvc *personas.Service
examPrepSvc *examprep.Service examPrepSvc *examprep.Service
programSvc *programs.Service programSvc *programs.Service
@ -89,6 +91,7 @@ func New(
questionsSvc *questions.Service, questionsSvc *questions.Service,
faqSvc *faqs.Service, faqSvc *faqs.Service,
emailTemplateSvc *emailtemplates.Service, emailTemplateSvc *emailtemplates.Service,
profileFieldOptionSvc *profilefieldoptions.Service,
personaSvc *personas.Service, personaSvc *personas.Service,
examPrepSvc *examprep.Service, examPrepSvc *examprep.Service,
programSvc *programs.Service, programSvc *programs.Service,
@ -126,6 +129,7 @@ func New(
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
faqSvc: faqSvc, faqSvc: faqSvc,
emailTemplateSvc: emailTemplateSvc, emailTemplateSvc: emailTemplateSvc,
profileFieldOptionSvc: profileFieldOptionSvc,
personaSvc: personaSvc, personaSvc: personaSvc,
examPrepSvc: examPrepSvc, examPrepSvc: examPrepSvc,
programSvc: programSvc, programSvc: programSvc,

View File

@ -17,6 +17,7 @@ func (a *App) initAppRoutes() {
a.questionsSvc, a.questionsSvc,
a.faqSvc, a.faqSvc,
a.emailTemplateSvc, a.emailTemplateSvc,
a.profileFieldOptionSvc,
a.personaSvc, a.personaSvc,
a.examPrepSvc, a.examPrepSvc,
a.programSvc, 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.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) 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 // Question Sets
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet) 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) groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)