Files
spsays-bff/internal/handler/user.go
T

140 lines
4.7 KiB
Go

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})
}