package handler import ( "encoding/json" "errors" "net/http" "strconv" "strings" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/pkg/response" ) // UserHandler exposes profile-edit + avatar-presign endpoints. // All routes here sit behind middleware.RequireAuth. type UserHandler struct { svc *service.UserService } func NewUserHandler(svc *service.UserService) *UserHandler { return &UserHandler{svc: svc} } // updateProfileReq accepts partial updates. Use pointers so the JSON decoder // can tell "client explicitly sent null" from "field not present" — the // service treats nil-name as "leave alone" and ""-name as an error. type updateProfileReq struct { Name *string `json:"name"` AvatarURL *string `json:"avatar_url"` Handle *string `json:"handle"` Country *string `json:"country"` // Devotional profile (user-editable). Sampradaya *string `json:"sampradaya"` Gender *string `json:"gender"` Initiated *bool `json:"initiated"` InitiatedAt *string `json:"initiated_at"` DailyChantingRounds *int `json:"daily_chanting_rounds"` // System-managed notification fields — accepted here so future logic // can set them via the same endpoint; no UI sends them today. PreferredChantingTime *string `json:"preferred_chanting_time"` PeakActiveTime *string `json:"peak_active_time"` } // UpdateProfile patches any subset of the editable profile fields. func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) var req updateProfileReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } u, err := h.svc.UpdateProfile(r.Context(), uid, service.UpdateProfileInput{ Name: req.Name, AvatarURL: req.AvatarURL, Handle: req.Handle, Country: req.Country, Sampradaya: req.Sampradaya, Gender: req.Gender, Initiated: req.Initiated, InitiatedAt: req.InitiatedAt, DailyChantingRounds: req.DailyChantingRounds, PreferredChantingTime: req.PreferredChantingTime, PeakActiveTime: req.PeakActiveTime, }) if err != nil { if errors.Is(err, repository.ErrHandleTaken) { response.Error(w, http.StatusConflict, "that handle is already in use") return } response.Error(w, http.StatusBadRequest, err.Error()) return } response.JSON(w, http.StatusOK, u) } // userSearchItem is the public-safe slice of model.User returned by the // search endpoint. Sensitive fields (email, password hash, role, plan, // token_version, timestamps) are intentionally omitted. type userSearchItem struct { Handle string `json:"handle"` Name string `json:"name"` AvatarURL *string `json:"avatar_url,omitempty"` } // Search handles GET /api/v1/users/search?q=...&limit=... // // Public endpoint — anonymous and signed-in viewers alike can search // for users by handle or display name. Returns a sanitized payload // without auth-sensitive fields. Used by the global /search page when // the query carries an "@" prefix. func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) if q == "" { response.JSON(w, http.StatusOK, map[string]any{ "query": "", "results": []userSearchItem{}, }) return } limit := 20 if v := r.URL.Query().Get("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 { limit = n } } users, err := h.svc.Search(r.Context(), q, limit) if err != nil { response.Error(w, http.StatusInternalServerError, "search failed") return } items := make([]userSearchItem, 0, len(users)) for _, u := range users { items = append(items, userSearchItem{ Handle: u.Handle, Name: u.Name, AvatarURL: u.AvatarURL, }) } response.JSON(w, http.StatusOK, map[string]any{ "query": q, "results": items, }) } // GetReadingPrefs returns the caller's saved reading prefs blob (always // well-formed JSON; an empty object when the user has never written). // The frontend treats the response as the source of truth on first // sign-in for this device. func (h *UserHandler) GetReadingPrefs(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) raw, err := h.svc.GetReadingPrefs(r.Context(), uid) if err != nil { response.Error(w, http.StatusInternalServerError, "could not load reading prefs") return } // Round-trip the JSONB as the data field directly. We use a raw // json.RawMessage so the body is `{"data": {...}}` not a re-encoded // string. The envelope wrapper keeps shape parity with the rest of // the API. response.JSON(w, http.StatusOK, json.RawMessage(raw)) } // UpdateReadingPrefs replaces the caller's reading-prefs blob with the // request body (which must itself be a JSON object — we re-marshal a // decoded map to enforce that without imposing a fixed schema on a // payload the frontend owns). func (h *UserHandler) UpdateReadingPrefs(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) // Decode into map[string]any to guarantee the body is an object (not // an array, number, or string). Re-marshal before persisting so the // stored JSON is canonicalised and any duplicate keys are dropped. var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body (expect an object)") return } prefsJSON, err := json.Marshal(body) if err != nil { response.Error(w, http.StatusBadRequest, "could not re-encode prefs") return } if err := h.svc.UpdateReadingPrefs(r.Context(), uid, prefsJSON); err != nil { response.Error(w, http.StatusInternalServerError, "could not save reading prefs") return } response.JSON(w, http.StatusOK, json.RawMessage(prefsJSON)) } type presignReq struct { ContentType string `json:"content_type"` } type presignResp struct { UploadURL string `json:"upload_url"` PublicURL string `json:"public_url"` } // AvatarPresign returns a one-shot R2 PUT URL the browser uses to upload. // The frontend then PATCHes /users/me with the returned public_url to // persist the new avatar on the user row. func (h *UserHandler) AvatarPresign(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) var req presignReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } ct := strings.TrimSpace(req.ContentType) if ct == "" { response.Error(w, http.StatusBadRequest, "content_type is required") return } upload, public, err := h.svc.AvatarPresign(r.Context(), uid, ct) if err != nil { // Differentiate "feature off" from "bad content type" so the frontend // can show a useful message. if strings.Contains(err.Error(), "not configured") { response.Error(w, http.StatusServiceUnavailable, err.Error()) return } response.Error(w, http.StatusBadRequest, err.Error()) return } response.JSON(w, http.StatusOK, presignResp{UploadURL: upload, PublicURL: public}) }