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:
parent
176f78515d
commit
a5acd00637
|
|
@ -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,
|
||||
|
|
|
|||
1
db/migrations/000069_profile_field_options.down.sql
Normal file
1
db/migrations/000069_profile_field_options.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS field_options;
|
||||
74
db/migrations/000069_profile_field_options.up.sql
Normal file
74
db/migrations/000069_profile_field_options.up.sql
Normal 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', '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');
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- No-op: keep field_options table name on rollback of 070 alone.
|
||||
|
|
@ -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 $$;
|
||||
77
internal/domain/profile_field_option.go
Normal file
77
internal/domain/profile_field_option.go
Normal 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"`
|
||||
}
|
||||
17
internal/ports/profile_field_option.go
Normal file
17
internal/ports/profile_field_option.go
Normal 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
|
||||
}
|
||||
214
internal/repository/profile_field_options.go
Normal file
214
internal/repository/profile_field_options.go
Normal 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
|
||||
}
|
||||
210
internal/services/profilefieldoptions/service.go
Normal file
210
internal/services/profilefieldoptions/service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ type Service struct {
|
|||
otpStore ports.OtpStore
|
||||
messengerSvc *messenger.Service
|
||||
emailTemplateSvc *emailtemplates.Service
|
||||
profileFieldOptionSvc *profilefieldoptions.Service
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
|
|
@ -27,6 +29,7 @@ func NewService(
|
|||
otpStore ports.OtpStore,
|
||||
messengerSvc *messenger.Service,
|
||||
emailTemplateSvc *emailtemplates.Service,
|
||||
profileFieldOptionSvc *profilefieldoptions.Service,
|
||||
cfg *config.Config,
|
||||
) *Service {
|
||||
return &Service{
|
||||
|
|
@ -35,6 +38,7 @@ func NewService(
|
|||
otpStore: otpStore,
|
||||
messengerSvc: messengerSvc,
|
||||
emailTemplateSvc: emailTemplateSvc,
|
||||
profileFieldOptionSvc: profileFieldOptionSvc,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -52,6 +53,7 @@ type App struct {
|
|||
questionsSvc *questions.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,
|
||||
|
|
@ -148,6 +151,7 @@ func NewApp(
|
|||
questionsSvc: questionsSvc,
|
||||
faqSvc: faqSvc,
|
||||
emailTemplateSvc: emailTemplateSvc,
|
||||
profileFieldOptionSvc: profileFieldOptionSvc,
|
||||
personaSvc: personaSvc,
|
||||
examPrepSvc: examPrepSvc,
|
||||
programSvc: programSvc,
|
||||
|
|
|
|||
303
internal/web_server/handlers/field_option.go
Normal file
303
internal/web_server/handlers/field_option.go
Normal 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()})
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -51,6 +52,7 @@ type Handler struct {
|
|||
questionsSvc *questions.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,
|
||||
|
|
@ -126,6 +129,7 @@ func New(
|
|||
questionsSvc: questionsSvc,
|
||||
faqSvc: faqSvc,
|
||||
emailTemplateSvc: emailTemplateSvc,
|
||||
profileFieldOptionSvc: profileFieldOptionSvc,
|
||||
personaSvc: personaSvc,
|
||||
examPrepSvc: examPrepSvc,
|
||||
programSvc: programSvc,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user