249 lines
7.8 KiB
Go
249 lines
7.8 KiB
Go
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 the `offset`/`limit` page of users matching the query
|
||
// (handle or name substring) plus the total match count for pagination.
|
||
// Used by the public user-search endpoint on the /search page.
|
||
func (s *UserService) Search(ctx context.Context, q string, limit, offset int) ([]model.User, int, error) {
|
||
return s.users.Search(ctx, q, limit, offset)
|
||
}
|
||
|
||
// 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/<user_id>/<random>.<ext>` 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
|
||
}
|
||
}
|