package admin import ( "context" "encoding/json" "errors" "regexp" "strings" "unicode/utf8" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" ) var ( ErrInvalidBanner = errors.New("invalid banner") ErrBannerConflict = errors.New("banner code conflict") bannerPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_./-]{1,200}$`) bannerPlacements = map[string]struct{}{"home": {}, "explore": {}, "ask": {}} bannerCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`) ) // BannerWriteBody is JSON for create/update. type BannerWriteBody struct { Code string `json:"code"` Title string `json:"title"` Placement string `json:"placement"` ImageURL *string `json:"image_url"` LinkPath *string `json:"link_path"` SortOrder int `json:"sort_order"` Active bool `json:"active"` } // CreateBanner validates, inserts, audits. func (s *Service) CreateBanner(ctx context.Context, adminID uuid.UUID, body BannerWriteBody) (*repository.BannerRow, error) { in, err := normalizeBannerWrite(body, true) if err != nil { return nil, err } meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active}) row, err := s.Repo.CreateBannerWithAudit(ctx, adminID, in, meta) if repository.BannerCodeConflict(err) { return nil, ErrBannerConflict } return row, err } // UpdateBanner validates, updates, audits. func (s *Service) UpdateBanner(ctx context.Context, adminID, id uuid.UUID, body BannerWriteBody) (*repository.BannerRow, error) { in, err := normalizeBannerWrite(body, true) if err != nil { return nil, err } meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active}) row, err := s.Repo.UpdateBannerWithAudit(ctx, adminID, id, in, meta) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrBannerNotFound } if repository.BannerCodeConflict(err) { return nil, ErrBannerConflict } return row, err } func normalizeBannerWrite(body BannerWriteBody, requireCode bool) (repository.BannerWriteInput, error) { code := strings.TrimSpace(body.Code) title := strings.TrimSpace(body.Title) placement := strings.TrimSpace(body.Placement) if requireCode && code == "" { return repository.BannerWriteInput{}, ErrInvalidBanner } if code != "" && !bannerCodeRe.MatchString(code) { return repository.BannerWriteInput{}, ErrInvalidBanner } if title == "" || utf8.RuneCountInString(title) > 128 { return repository.BannerWriteInput{}, ErrInvalidBanner } if _, ok := bannerPlacements[placement]; !ok { return repository.BannerWriteInput{}, ErrInvalidBanner } var link *string if body.LinkPath != nil { lp := strings.TrimSpace(*body.LinkPath) if lp == "" { link = nil } else { if strings.Contains(lp, "://") || !bannerPathRe.MatchString(lp) { return repository.BannerWriteInput{}, ErrInvalidBanner } link = &lp } } var img *string if body.ImageURL != nil { u := strings.TrimSpace(*body.ImageURL) if u == "" { img = nil } else if utf8.RuneCountInString(u) > 2000 { return repository.BannerWriteInput{}, ErrInvalidBanner } else { img = &u } } return repository.BannerWriteInput{ Code: code, Title: title, Placement: placement, ImageURL: img, LinkPath: link, SortOrder: body.SortOrder, Active: body.Active, }, nil }