package handler import ( "encoding/json" "errors" "net/http" "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"` } // UpdateProfile patches name, avatar, and/or handle. 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, req.Name, req.AvatarURL, req.Handle) 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) } // 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}) }