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)
|
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 {
|
type presignReq struct {
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,41 @@ func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error
|
|||||||
return nil
|
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.
|
// MarkEmailVerified sets email_verified_at = now() if not already set.
|
||||||
func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error {
|
func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error {
|
||||||
const q = `UPDATE users SET email_verified_at = now(), updated_at = now()
|
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.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
r.Patch("/me", s.userH.UpdateProfile)
|
r.Patch("/me", s.userH.UpdateProfile)
|
||||||
r.Post("/me/avatar-presign", s.userH.AvatarPresign)
|
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)
|
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
|
// normalizeHandle lowercases and strips anything that isn't [a-z0-9-]. Same
|
||||||
// rules as slugForHandle in auth_service, but exposed here because the
|
// rules as slugForHandle in auth_service, but exposed here because the
|
||||||
// profile-update path doesn't go through auth.
|
// 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