package home import ( "context" "encoding/json" "errors" "regexp" "strings" "unicode/utf8" "github.com/google/uuid" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" ) var ( ErrInvalidTools = errors.New("invalid home tools") ErrTooManyTools = errors.New("too many home tools") ) var pathRe = regexp.MustCompile(`^/[a-zA-Z0-9_./-]{1,120}$`) var allowedIcons = map[string]struct{}{ "mbti": {}, "star": {}, "portrait": {}, "rhythm": {}, "synastry": {}, "astro": {}, "companion": {}, "ask": {}, "cards": {}, "reports": {}, "growth": {}, "relation": {}, } // Service serves homepage tool catalog. type Service struct { Repo *repository.HomeToolsRepo } // ListPublic returns enabled tools. func (s *Service) ListPublic(ctx context.Context) ([]repository.HomeTool, error) { return s.Repo.ListEnabled(ctx) } // ListAdmin returns all tools. func (s *Service) ListAdmin(ctx context.Context) ([]repository.HomeTool, error) { return s.Repo.ListAll(ctx) } // ReplaceInput is one tool in a PUT body (id optional). type ReplaceInput struct { ID string `json:"id"` RowIndex int `json:"row_index"` SortOrder int `json:"sort_order"` Path string `json:"path"` Icon string `json:"icon"` Label string `json:"label"` Badge *string `json:"badge"` BadgeTone *string `json:"badge_tone"` Enabled bool `json:"enabled"` } // Replace validates and replaces all tools. func (s *Service) Replace(ctx context.Context, items []ReplaceInput) error { return s.ReplaceWithAudit(ctx, uuid.Nil, items, nil) } // ReplaceWithAudit validates, replaces, and audits in one transaction when adminID set. func (s *Service) ReplaceWithAudit( ctx context.Context, adminID uuid.UUID, items []ReplaceInput, meta json.RawMessage, ) error { if len(items) == 0 { return ErrInvalidTools } if len(items) > 24 { return ErrTooManyTools } rows := make([]repository.HomeTool, 0, len(items)) for _, in := range items { t, err := normalizeTool(in) if err != nil { return err } rows = append(rows, *t) } return s.Repo.ReplaceAllWithAudit(ctx, rows, adminID, meta) } func normalizeTool(in ReplaceInput) (*repository.HomeTool, error) { if in.RowIndex != 1 && in.RowIndex != 2 { return nil, ErrInvalidTools } path := strings.TrimSpace(in.Path) if !pathRe.MatchString(path) || strings.Contains(path, "..") { return nil, ErrInvalidTools } icon := strings.TrimSpace(in.Icon) if _, ok := allowedIcons[icon]; !ok { return nil, ErrInvalidTools } label := strings.TrimSpace(in.Label) n := utf8.RuneCountInString(label) if n < 1 || n > 16 { return nil, ErrInvalidTools } var badge, tone *string if in.Badge != nil { b := strings.TrimSpace(*in.Badge) if b != "" { if utf8.RuneCountInString(b) > 4 { return nil, ErrInvalidTools } badge = &b } } if in.BadgeTone != nil { t := strings.TrimSpace(*in.BadgeTone) if t != "" && t != "hot" && t != "new" { return nil, ErrInvalidTools } if t != "" { tone = &t } } id := uuid.Nil if strings.TrimSpace(in.ID) != "" { parsed, err := uuid.Parse(in.ID) if err != nil { return nil, ErrInvalidTools } id = parsed } return &repository.HomeTool{ ID: id, RowIndex: in.RowIndex, SortOrder: in.SortOrder, Path: path, Icon: icon, Label: label, Badge: badge, BadgeTone: tone, Enabled: in.Enabled, }, nil }