bada72f87e
Track each authenticated user's last-viewed route + light view state server-side, and on next sign-in redirect them to that state, falling back to the empty-state home only when there's no recorded state. Backend: - migration 022_user_session_state.sql: one row per user (user_id PK/FK, last_route, last_route_state JSON-as-TEXT, resume_enabled default 1, last_updated_at). - PUT /api/me/last-state (require_user): upserts route + light state; no-ops when resume_enabled=0. Localized in the /me region. - /api/auth/me payload now carries resume_enabled + last_route + decoded last_route_state (no extra round-trip). - test_session_resume_vertical.py: auth-required, upsert/read-back, per-user isolation, resume_enabled=0 disable. Frontend: - lib/useLastState.js: debounced (~1s) route-change PUT for authenticated users; one-time resume redirect on sign-in, gated on identify having fired (preserves #21 Part C identify-then-track). - api.js: putLastState() client call. - App.jsx: import + call the hook; set identifyReady after identify. Header region untouched. Per-user (not per-device); profile-settings opt-out toggle UI deferred (column + default-on behavior ship now). Stored state is route + light view state ONLY, never draft buffers — documented in SPEC §6.8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
55 lines
2.7 KiB
SQL
55 lines
2.7 KiB
SQL
-- v0.23.0 / roadmap item #29: server-side sign-in state resume.
|
|
--
|
|
-- Track each authenticated user's last-viewed route + a small bag of
|
|
-- "light" component state so that the *next* sign-in can land the user
|
|
-- back where they left off, rather than always dropping them on the
|
|
-- empty-state home view.
|
|
--
|
|
-- Storage shape (one row per user — per-user, NOT per-device, per the
|
|
-- #29 "safe default"):
|
|
--
|
|
-- * `user_id` — PRIMARY KEY and FK into users(id) with cascade on
|
|
-- delete. INTEGER to match users.id (INTEGER PRIMARY KEY
|
|
-- AUTOINCREMENT). A deleted user automatically loses their stored
|
|
-- resume state. One row per user means a later sign-in on any
|
|
-- device resumes the most-recently-recorded route — the per-user
|
|
-- model the roadmap asks for.
|
|
--
|
|
-- * `last_route` — the frontend pathname the user was last on
|
|
-- (e.g. "/rfc/open-human-model"). TEXT, nullable until the first
|
|
-- route-change POST lands. NEVER contains draft-buffer contents —
|
|
-- it is a route only. See SPEC §6.2 "Sign-in state resume
|
|
-- (privacy)".
|
|
--
|
|
-- * `last_route_state` — a JSON-encoded bag of *light* component
|
|
-- state (scroll anchors, open-tab selection, filter chips, etc.).
|
|
-- SQLite has no native JSONB; we store JSON as TEXT exactly as the
|
|
-- rest of the app stores its JSON blobs (json.dumps / json.loads,
|
|
-- cf. permission_events.details, actions.details). Nullable.
|
|
-- PRIVACY INVARIANT: this column MUST NOT carry draft-buffer text,
|
|
-- PR bodies, comment drafts, or any user-typed content — only
|
|
-- ephemeral view state safe to replay. The PUT handler is the
|
|
-- enforcement point; the column comment is the contract.
|
|
--
|
|
-- * `resume_enabled` — the per-user opt-out flag. 1 (default) means
|
|
-- "resume me where I left off"; 0 means "always land on home". The
|
|
-- PUT handler no-ops the upsert when this is 0, and the read path
|
|
-- refuses to hand back a stored route when this is 0. A
|
|
-- profile-settings toggle UI to flip this is a follow-up (the
|
|
-- column + default-on behavior ship now); see CHANGELOG v0.23.0.
|
|
--
|
|
-- * `last_updated_at` — TEXT timestamp, app convention
|
|
-- `datetime('now')`, matching device_trust.last_seen_at /
|
|
-- users.last_seen_at. Refreshed on every successful upsert.
|
|
--
|
|
-- No new env vars. The debounce interval for the frontend route-change
|
|
-- POST is a frontend constant (~1s), not a server knob.
|
|
|
|
CREATE TABLE user_session_state (
|
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
last_route TEXT,
|
|
last_route_state TEXT, -- JSON-encoded light state, nullable
|
|
resume_enabled INTEGER NOT NULL DEFAULT 1,
|
|
last_updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|