add reading settings sync

This commit is contained in:
Krispire Admin
2026-05-31 07:07:36 +05:30
parent 430747536d
commit 05b61cef09
6 changed files with 116 additions and 0 deletions
+45
View File
@@ -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"`
}
+35
View File
@@ -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()
+6
View File
@@ -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)
})
})
+14
View File
@@ -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;