package service import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "path" "strings" "time" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" ) // UserService handles profile-edit + avatar-upload flows. type UserService struct { users *repository.UserRepo r2 *repository.R2 } func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService { return &UserService{users: users, r2: r2} } // UpdateProfile updates the user's name, avatar URL, and/or handle. // // `avatarURL` semantics: // // nil → don't touch // "" → clear (remove avatar) // "x" → set to "x" // // `handle` is normalized to the project's slug format before being passed // to the DB so users can't sneak in spaces or punctuation. Empty after // normalization → 400. // UpdateProfileInput is the partial profile patch a handler hands the // service. Every field is a pointer so nil means "leave unchanged". The // service validates/normalizes each present field, then maps to a // repository.ProfileUpdate. type UpdateProfileInput struct { Name *string AvatarURL *string Handle *string Country *string Sampradaya *string Gender *string Initiated *bool InitiatedAt *string DailyChantingRounds *int PreferredChantingTime *string PeakActiveTime *string } // validGenders is the accepted set; "" clears the field. var validGenders = map[string]bool{"male": true, "female": true, "other": true} // validSampradayas is the accepted lineage set. Currently just Gaudiya // (the default); add codes here as more lineages are supported. var validSampradayas = map[string]bool{"gaudiya": true} func (s *UserService) UpdateProfile(ctx context.Context, id string, in UpdateProfileInput) (*model.User, error) { p := repository.ProfileUpdate{ AvatarURL: in.AvatarURL, // passthrough: nil=leave, ""=clear, x=set Initiated: in.Initiated, DailyChantingRounds: in.DailyChantingRounds, } if in.Name != nil { trimmed := strings.TrimSpace(*in.Name) if trimmed == "" { return nil, errors.New("name cannot be empty") } if len(trimmed) > 120 { return nil, errors.New("name too long (max 120)") } p.Name = &trimmed } if in.Handle != nil { clean := normalizeHandle(*in.Handle) if len(clean) < 3 { return nil, errors.New("handle must be at least 3 characters (letters, digits, dashes)") } if len(clean) > 32 { return nil, errors.New("handle too long (max 32)") } p.Handle = &clean } if in.Country != nil { // "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the // frontend dropdown only ever sends valid codes). cc := strings.ToUpper(strings.TrimSpace(*in.Country)) if cc != "" && len(cc) != 2 { return nil, errors.New("country must be a 2-letter code") } p.Country = &cc } if in.Sampradaya != nil { // "" clears (optional field); otherwise must be a known lineage. s := strings.ToLower(strings.TrimSpace(*in.Sampradaya)) if s != "" && !validSampradayas[s] { return nil, errors.New("unknown sampradaya") } p.Sampradaya = &s } if in.Gender != nil { g := strings.ToLower(strings.TrimSpace(*in.Gender)) if g != "" && !validGenders[g] { return nil, errors.New("gender must be male, female, or other") } p.Gender = &g } if in.InitiatedAt != nil { d := strings.TrimSpace(*in.InitiatedAt) if d != "" { if _, err := time.Parse("2006-01-02", d); err != nil { return nil, errors.New("initiated_at must be a date (YYYY-MM-DD)") } } p.InitiatedAt = &d } if in.DailyChantingRounds != nil { // 0 is the clear sentinel; real values are 1–1008 (DB enforces too). n := *in.DailyChantingRounds if n != 0 && (n < 1 || n > 1008) { return nil, errors.New("daily_chanting_rounds must be between 1 and 1008") } } if in.PreferredChantingTime != nil { t, err := normalizeClock(*in.PreferredChantingTime) if err != nil { return nil, errors.New("preferred_chanting_time must be HH:MM") } p.PreferredChantingTime = &t } if in.PeakActiveTime != nil { t, err := normalizeClock(*in.PeakActiveTime) if err != nil { return nil, errors.New("peak_active_time must be HH:MM") } p.PeakActiveTime = &t } return s.users.UpdateProfile(ctx, id, p) } // normalizeClock validates an "HH:MM" 24-hour clock time, returning "" for // a blank input (clear). The system-managed notification fields use this. func normalizeClock(raw string) (string, error) { t := strings.TrimSpace(raw) if t == "" { return "", nil } if _, err := time.Parse("15:04", t); err != nil { return "", err } return t, nil } // Search returns users matching the query (handle or name substring). // Used by the public user-search endpoint on the /search page. func (s *UserService) Search(ctx context.Context, q string, limit int) ([]model.User, error) { return s.users.Search(ctx, q, limit) } // GetReadingPrefs returns the user's reading prefs blob (opaque JSON). // Used by the frontend to hydrate ReadingSettings on sign-in so the // theme/palette/font choices roam across devices. func (s *UserService) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) { return s.users.GetReadingPrefs(ctx, id) } // UpdateReadingPrefs overwrites the user's reading prefs blob. The // handler re-marshals the decoded request before calling in here so we // know `prefsJSON` is well-formed by the time it hits the DB. func (s *UserService) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error { return s.users.UpdateReadingPrefs(ctx, id, prefsJSON) } // normalizeHandle lowercases and strips anything that isn't [a-z0-9-]. Same // rules as slugForHandle in auth_service, but exposed here because the // profile-update path doesn't go through auth. func normalizeHandle(raw string) string { var b strings.Builder for _, r := range strings.ToLower(strings.TrimSpace(raw)) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) case r == '-', r == '.', r == '_': b.WriteByte('-') } } out := strings.Trim(b.String(), "-") // Collapse runs of dashes. for strings.Contains(out, "--") { out = strings.ReplaceAll(out, "--", "-") } return out } // AvatarPresign issues a one-shot presigned PUT URL the frontend uses to // upload an avatar directly to R2. // // The key is namespaced under `avatars//.` so: // - we can list / clean up a user's avatars with one prefix scan // - the random suffix forces cache invalidation when the avatar changes // - a malicious user can't overwrite someone else's avatar (their JWT // scopes the user_id portion of the key) // // Returns (uploadURL, publicURL, error). The frontend then PUTs to uploadURL // and POSTs publicURL back as the new avatar. func (s *UserService) AvatarPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) { if s.r2 == nil { return "", "", errors.New("avatar uploads are not configured") } ext, ok := extensionFor(contentType) if !ok { return "", "", fmt.Errorf("unsupported content type: %s", contentType) } suffix := make([]byte, 8) if _, err := rand.Read(suffix); err != nil { return "", "", err } key := path.Join("avatars", userID, hex.EncodeToString(suffix)+ext) return s.r2.PresignPut(ctx, key, contentType, 5*time.Minute) } // extensionFor whitelists the image content types we accept. Restricting // here is the cheap-but-effective defence against users uploading arbitrary // binaries to our R2 bucket. func extensionFor(ct string) (string, bool) { switch ct { case "image/png": return ".png", true case "image/jpeg": return ".jpg", true case "image/webp": return ".webp", true case "image/gif": return ".gif", true default: return "", false } }