add reading settings sync
This commit is contained in:
@@ -53,6 +53,51 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -152,6 +152,41 @@ func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetReadingPrefs returns the raw JSONB bytes for the user's reading
|
||||
// prefs (empty object when never set). The frontend owns the shape, so
|
||||
// we round-trip the payload verbatim — no server-side schema enforced.
|
||||
func (r *UserRepo) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) {
|
||||
const q = `SELECT reading_prefs FROM users WHERE id = $1 LIMIT 1`
|
||||
var raw []byte
|
||||
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(&raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get reading prefs: %w", err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
raw = []byte("{}")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// UpdateReadingPrefs overwrites the user's reading prefs blob. Caller is
|
||||
// responsible for validating that `prefsJSON` is well-formed JSON (the
|
||||
// handler does this by re-marshalling the decoded struct before calling
|
||||
// in).
|
||||
func (r *UserRepo) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error {
|
||||
const q = `UPDATE users SET reading_prefs = $2::jsonb, updated_at = now() WHERE id = $1`
|
||||
tag, err := r.pg.Pool.Exec(ctx, q, id, prefsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update reading prefs: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkEmailVerified sets email_verified_at = now() if not already set.
|
||||
func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error {
|
||||
const q = `UPDATE users SET email_verified_at = now(), updated_at = now()
|
||||
|
||||
@@ -485,6 +485,12 @@ func (s *Server) setupRouter() {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Patch("/me", s.userH.UpdateProfile)
|
||||
r.Post("/me/avatar-presign", s.userH.AvatarPresign)
|
||||
// Reading prefs roam with the account so theme/palette/
|
||||
// font picks follow the user across devices. Body shape
|
||||
// is owned by the frontend; the server treats it as an
|
||||
// opaque JSON object.
|
||||
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
|
||||
r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -59,6 +59,20 @@ func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatar
|
||||
return s.users.UpdateProfile(ctx, id, name, avatarURL, handle)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS reading_prefs;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Adds per-user reading preferences (theme palette, font family, font
|
||||
-- size, justification toggle) so the prefs roam across devices once the
|
||||
-- user signs in.
|
||||
--
|
||||
-- Stored as JSONB rather than a column-per-field — the shape is owned
|
||||
-- by the frontend (components/reading-settings.tsx), changes
|
||||
-- occasionally (new palettes added every now and then), and we never
|
||||
-- query individual keys server-side. JSONB keeps the schema stable as
|
||||
-- the shape evolves.
|
||||
--
|
||||
-- Default `'{}'::jsonb` so existing rows are not nulled and the
|
||||
-- frontend can treat the server response as "merge into local defaults"
|
||||
-- without an additional null check.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS reading_prefs JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
Reference in New Issue
Block a user