Files
spsays-bff/internal/service/user_service.go
T

249 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}