From 05b61cef095c1987f340a872fdb95ff7e2e94939 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sun, 31 May 2026 07:07:36 +0530 Subject: [PATCH] add reading settings sync --- internal/handler/user.go | 45 +++++++++++++++++++ internal/repository/user_repo.go | 35 +++++++++++++++ internal/server/server.go | 6 +++ internal/service/user_service.go | 14 ++++++ .../0009_add_user_reading_prefs.down.sql | 1 + migrations/0009_add_user_reading_prefs.up.sql | 15 +++++++ 6 files changed, 116 insertions(+) create mode 100644 migrations/0009_add_user_reading_prefs.down.sql create mode 100644 migrations/0009_add_user_reading_prefs.up.sql diff --git a/internal/handler/user.go b/internal/handler/user.go index e3927ca..fcf5f58 100644 --- a/internal/handler/user.go +++ b/internal/handler/user.go @@ -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"` } diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index 0874dd3..314d4a1 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -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() diff --git a/internal/server/server.go b/internal/server/server.go index c8aa548..ce0f19e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) }) }) diff --git a/internal/service/user_service.go b/internal/service/user_service.go index f4afdb2..c6f69c5 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -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. diff --git a/migrations/0009_add_user_reading_prefs.down.sql b/migrations/0009_add_user_reading_prefs.down.sql new file mode 100644 index 0000000..10ab093 --- /dev/null +++ b/migrations/0009_add_user_reading_prefs.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS reading_prefs; diff --git a/migrations/0009_add_user_reading_prefs.up.sql b/migrations/0009_add_user_reading_prefs.up.sql new file mode 100644 index 0000000..a7874f1 --- /dev/null +++ b/migrations/0009_add_user_reading_prefs.up.sql @@ -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;