Prefs: expose user_ai_prefs — GET/PUT /prefs + GET /timezones (existing columns, no migration) #126

Closed
opened 2026-09-27 15:04:33 +00:00 by cameron · 2 comments
Owner

Part of #113

What to build

First-class settings surface for the user_ai_prefs row. Today display_tz,
voice, and library are only writable incidentally — a POST /reels
body mirrors them via capture_prefs. An owner who never creates a reel has
no way to set their display zone, and there is no read path for any pref.
FileViewer-React currently persists prefs on-device; these endpoints are the
sync/migration target for that state.

Endpoints:

  • GET /prefs — returns the current row (voice, library, display_tz,
    updated_at) or an empty shape when no row exists. Auth required like the
    other protected routes.
  • PUT /prefs — partial update: a field omitted from the body is
    unchanged; a field present is set (explicit null clears). display_tz
    is validated against chrono-tz (same check as display_zone_for); an
    invalid zone name → 400, the row is not partially written.
  • GET /timezones — the IANA zone list from chrono-tz, for picker UIs
    (unauthenticated is fine; it's static data).

Scope: existing columns only — no migration. capture_prefs on
reel creation stays as-is (idempotent with this surface; same write).
Follow-on owner defaults (model/backend/persona/reels) are a separate
ticket that adds the columns.

Acceptance criteria

  • GET /prefs returns the stored row; empty/nullable shape when none.
  • PUT /prefs updates only the fields present in the body; others untouched.
  • Invalid display_tz (e.g. Not/A_Zone) → 400, nothing written.
  • GET /timezones returns the IANA list (includes America/New_York).
  • A PUT /prefs display_tz change is reflected by /memories grouping
    and the reels pregen zone on the next read/pregen (prefs row is the
    first source in load_pregen_params).
  • Routes auth-gated; handler + endpoint tests in the existing style.

Cross-references

  • FileViewer-React: settings-page ticket (separate repo, filed after this lands).
  • Map #113: display zone is the contract's operator-settable knob (ADR-0001).
Part of #113 ## What to build First-class settings surface for the `user_ai_prefs` row. Today `display_tz`, `voice`, and `library` are only writable *incidentally* — a `POST /reels` body mirrors them via `capture_prefs`. An owner who never creates a reel has no way to set their display zone, and there is no read path for any pref. FileViewer-React currently persists prefs on-device; these endpoints are the sync/migration target for that state. Endpoints: - `GET /prefs` — returns the current row (voice, library, display_tz, updated_at) or an empty shape when no row exists. Auth required like the other protected routes. - `PUT /prefs` — partial update: a field omitted from the body is unchanged; a field present is set (explicit null clears). `display_tz` is validated against chrono-tz (same check as `display_zone_for`); an invalid zone name → 400, the row is not partially written. - `GET /timezones` — the IANA zone list from chrono-tz, for picker UIs (unauthenticated is fine; it's static data). Scope: **existing columns only — no migration.** `capture_prefs` on reel creation stays as-is (idempotent with this surface; same write). Follow-on owner defaults (model/backend/persona/reels) are a separate ticket that adds the columns. ## Acceptance criteria - [ ] `GET /prefs` returns the stored row; empty/nullable shape when none. - [ ] `PUT /prefs` updates only the fields present in the body; others untouched. - [ ] Invalid `display_tz` (e.g. `Not/A_Zone`) → 400, nothing written. - [ ] `GET /timezones` returns the IANA list (includes `America/New_York`). - [ ] A `PUT /prefs` `display_tz` change is reflected by `/memories` grouping and the reels pregen zone on the next read/pregen (prefs row is the first source in `load_pregen_params`). - [ ] Routes auth-gated; handler + endpoint tests in the existing style. ## Cross-references - FileViewer-React: settings-page ticket (separate repo, filed after this lands). - Map #113: display zone is the contract's operator-settable knob (ADR-0001).
cameron self-assigned this 2026-09-27 17:35:56 +00:00
Author
Owner

Implemented — PR #128 (branch feature/prefs-endpoints, commit 2258a0e).

What shipped:

  • GET /prefs (JWT) → {voice, library, display_tz, updated_at} or the all-null shape; 200 when no row.
  • PUT /prefs (JWT) → three-state partial update: omitted = unchanged, explicit null = clear, value = set. display_tz validated against chrono-tz before any write — invalid → 400, nothing written (not even other valid fields). Empty body → 200 no-op, no row created. Frozen tz_offset_minutes preserved verbatim.
  • GET /timezones (public) → chrono_tz::TZ_VARIANTS — exactly the domain Tz::from_str accepts, so the picker list and the validation check can't drift.
  • No migration; existing columns only. capture_prefs on POST /reels untouched — both writers upsert the same single row.

Evidence: 14 new tests (3 GET, 7 PUT incl. auth-gate, 2 timezones, 2 pure merge_patch) — the 3-state semantics needed a hand-rolled TriState deserializer because serde derive maps explicit null on Option<Option<T>> to the outer None, making "clear" indistinguishable from "omitted". Full suite 1436 passed / 0 failed. Clippy + fmt clean. Two-axis review (spec + standards) clean — all 9 spec requirements and all 10 house conventions verified.

Awaiting merge.

Implemented — PR #128 (branch `feature/prefs-endpoints`, commit `2258a0e`). **What shipped:** - `GET /prefs` (JWT) → `{voice, library, display_tz, updated_at}` or the all-null shape; 200 when no row. - `PUT /prefs` (JWT) → three-state partial update: omitted = unchanged, explicit null = clear, value = set. `display_tz` validated against chrono-tz before any write — invalid → 400, nothing written (not even other valid fields). Empty body → 200 no-op, no row created. Frozen `tz_offset_minutes` preserved verbatim. - `GET /timezones` (public) → `chrono_tz::TZ_VARIANTS` — exactly the domain `Tz::from_str` accepts, so the picker list and the validation check can't drift. - No migration; existing columns only. `capture_prefs` on POST /reels untouched — both writers upsert the same single row. **Evidence:** 14 new tests (3 GET, 7 PUT incl. auth-gate, 2 timezones, 2 pure merge_patch) — the 3-state semantics needed a hand-rolled `TriState` deserializer because serde derive maps explicit null on `Option<Option<T>>` to the outer `None`, making "clear" indistinguishable from "omitted". Full suite 1436 passed / 0 failed. Clippy + fmt clean. Two-axis review (spec + standards) clean — all 9 spec requirements and all 10 house conventions verified. Awaiting merge.
Author
Owner

Resolved — PR #128 merged to master.

  • src/prefs.rs: GET/PUT /prefs (JWT) + GET /timezones (public). Three-state partial update via a hand-rolled TriState deserializer (omitted = unchanged, explicit null = clear, value = set) — bare Option<Option<T>> derive conflates omitted with null, and serde_with::double_option was considered but the repo has no serde_with dep.
  • display_tz validated against chrono-tz before any write; invalid → 400, nothing written. Frozen tz_offset_minutes preserved verbatim on every write. /timezones returns chrono_tz::TZ_VARIANTS — exactly the domain Tz::from_str accepts.
  • No migration; existing columns only. capture_prefs (POST /reels) untouched.
  • 14 new tests; full suite 1436/0; clippy + fmt clean; two-axis review (spec + standards) clean.

Unblocks #127 (owner defaults).

**Resolved** — PR #128 merged to master. - `src/prefs.rs`: GET/PUT `/prefs` (JWT) + GET `/timezones` (public). Three-state partial update via a hand-rolled `TriState` deserializer (omitted = unchanged, explicit null = clear, value = set) — bare `Option<Option<T>>` derive conflates omitted with null, and `serde_with::double_option` was considered but the repo has no `serde_with` dep. - `display_tz` validated against chrono-tz before any write; invalid → 400, nothing written. Frozen `tz_offset_minutes` preserved verbatim on every write. `/timezones` returns `chrono_tz::TZ_VARIANTS` — exactly the domain `Tz::from_str` accepts. - No migration; existing columns only. `capture_prefs` (POST /reels) untouched. - 14 new tests; full suite 1436/0; clippy + fmt clean; two-axis review (spec + standards) clean. Unblocks #127 (owner defaults).
cameron added the wayfinder:task label 2026-09-30 00:47:22 +00:00
Sign in to join this conversation.