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"
|
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,
|
||||||
|
|
|
||||||
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.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",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -13,12 +14,13 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
tokenStore ports.TokenStore
|
tokenStore ports.TokenStore
|
||||||
userStore ports.UserStore
|
userStore ports.UserStore
|
||||||
otpStore ports.OtpStore
|
otpStore ports.OtpStore
|
||||||
messengerSvc *messenger.Service
|
messengerSvc *messenger.Service
|
||||||
emailTemplateSvc *emailtemplates.Service
|
emailTemplateSvc *emailtemplates.Service
|
||||||
config *config.Config
|
profileFieldOptionSvc *profilefieldoptions.Service
|
||||||
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
|
|
@ -27,14 +29,16 @@ 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{
|
||||||
tokenStore: tokenStore,
|
tokenStore: tokenStore,
|
||||||
userStore: userStore,
|
userStore: userStore,
|
||||||
otpStore: otpStore,
|
otpStore: otpStore,
|
||||||
messengerSvc: messengerSvc,
|
messengerSvc: messengerSvc,
|
||||||
emailTemplateSvc: emailTemplateSvc,
|
emailTemplateSvc: emailTemplateSvc,
|
||||||
config: cfg,
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -50,8 +51,9 @@ import (
|
||||||
type App struct {
|
type App struct {
|
||||||
assessmentSvc *assessment.Service
|
assessmentSvc *assessment.Service
|
||||||
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,
|
||||||
|
|
@ -146,8 +149,9 @@ func NewApp(
|
||||||
s := &App{
|
s := &App{
|
||||||
assessmentSvc: assessmentSvc,
|
assessmentSvc: assessmentSvc,
|
||||||
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,
|
||||||
|
|
|
||||||
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"
|
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"
|
||||||
|
|
@ -49,8 +50,9 @@ import (
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
assessmentSvc *assessment.Service
|
assessmentSvc *assessment.Service
|
||||||
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,
|
||||||
|
|
@ -124,8 +127,9 @@ func New(
|
||||||
return &Handler{
|
return &Handler{
|
||||||
assessmentSvc: assessmentSvc,
|
assessmentSvc: assessmentSvc,
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user