feat(prefs): user_ai_prefs surface — GET/PUT /prefs, GET /timezones (#126) #128

Merged
cameron merged 1 commits from feature/prefs-endpoints into master 2026-09-27 21:38:24 +00:00
Owner

Summary

 src/
 ├── main.rs
+│    └── mod prefs; routes: /prefs (GET+PUT, JWT), /timezones (GET, public)
 ├── lib.rs
+│    └── pub mod prefs
+└── prefs.rs  # user_ai_prefs surface: GET /prefs, PUT /prefs (3-state partial), GET /timezones
on(put prefs)
  if display_tz present && !Tz::from_str(trimmed)
    return 400            # nothing written, not even other valid fields
  for each field in (voice, library, display_tz)
    omitted  -> existing row value (or None)
    null     -> None
    value    -> Some(v)
  if all omitted
    return 200 current row   # no-op, no row created, updated_at untouched
  upsert(merged, tz_offset_minutes = existing verbatim)
  return 200 {voice, library, display_tz, updated_at}

GET /timezones: chrono_tz::TZ_VARIANTS — exactly the domain Tz::from_str
accepts, so the picker list and the PUT validation can't drift apart.

Evidence

  • 14 new tests green: cargo test --lib prefs:: → 14 passed (3 GET /prefs,
    7 PUT /prefs incl. auth-gate, 2 GET /timezones, 2 pure merge_patch).
  • The 3-state wire semantics are the subtle part: serde's derive maps an
    explicit JSON null on Option<Option<T>> to the outer None, making
    "clear" indistinguishable from "omitted". The hand-rolled TriState
    visitor keeps the three apart; put_prefs_explicit_null_clears_field
    fails (voice stays set) without it.
  • cargo clippy --all-targets clean; cargo fmt --check clean.
  • Full suite: 1436 passed / 0 failed (14 binaries)

Merge Danger

Door: Two-way — additive routes only. No migration, no changed behavior on
any existing endpoint. Revert removes the three routes.

Blast Radius: Minimal. New routes only. The user_ai_prefs row is shared
with the existing capture_prefs writer (POST /reels); both writers upsert the
same single row, and the frozen legacy tz_offset_minutes column is preserved
verbatim on every write from this surface.

## Summary ```diff src/ ├── main.rs +│ └── mod prefs; routes: /prefs (GET+PUT, JWT), /timezones (GET, public) ├── lib.rs +│ └── pub mod prefs +└── prefs.rs # user_ai_prefs surface: GET /prefs, PUT /prefs (3-state partial), GET /timezones ``` ```text on(put prefs) if display_tz present && !Tz::from_str(trimmed) return 400 # nothing written, not even other valid fields for each field in (voice, library, display_tz) omitted -> existing row value (or None) null -> None value -> Some(v) if all omitted return 200 current row # no-op, no row created, updated_at untouched upsert(merged, tz_offset_minutes = existing verbatim) return 200 {voice, library, display_tz, updated_at} ``` GET /timezones: `chrono_tz::TZ_VARIANTS` — exactly the domain `Tz::from_str` accepts, so the picker list and the PUT validation can't drift apart. ## Evidence - 14 new tests green: `cargo test --lib prefs::` → 14 passed (3 GET /prefs, 7 PUT /prefs incl. auth-gate, 2 GET /timezones, 2 pure merge_patch). - The 3-state wire semantics are the subtle part: serde's derive maps an explicit JSON null on `Option<Option<T>>` to the outer `None`, making "clear" indistinguishable from "omitted". The hand-rolled `TriState` visitor keeps the three apart; `put_prefs_explicit_null_clears_field` fails (voice stays set) without it. - `cargo clippy --all-targets` clean; `cargo fmt --check` clean. - Full suite: 1436 passed / 0 failed (14 binaries) ## Merge Danger **Door:** Two-way — additive routes only. No migration, no changed behavior on any existing endpoint. Revert removes the three routes. **Blast Radius:** Minimal. New routes only. The `user_ai_prefs` row is shared with the existing `capture_prefs` writer (POST /reels); both writers upsert the same single row, and the frozen legacy `tz_offset_minutes` column is preserved verbatim on every write from this surface.
cameron added 1 commit 2026-09-27 18:40:49 +00:00
- GET /prefs (JWT): current row or empty {voice, library, display_tz,
  updated_at} shape — snake_case, 200 with nulls when no row
- PUT /prefs (JWT): three-state partial update (omitted=unchanged,
  explicit null=clear, value=set) via hand-rolled TriState deserializer —
  serde derive conflates omitted with explicit null on Option<Option<T>>
- display_tz validated against chrono-tz before any write; invalid → 400,
  nothing written. tz_offset_minutes (frozen legacy) preserved verbatim
- GET /timezones (public): chrono_tz::TZ_VARIANTS — exactly the domain
  Tz::from_str accepts, so picker list and validation can't drift
- No migration; existing columns only. capture_prefs (POST /reels) untouched
- 14 tests: 3 GET /prefs, 7 PUT /prefs (incl. auth-gate), 2 /timezones,
  2 pure merge_patch. Full suite 1436 passed / 0 failed
cameron merged commit aa3c58f051 into master 2026-09-27 21:38:24 +00:00
cameron deleted branch feature/prefs-endpoints 2026-09-27 21:38:24 +00:00
Sign in to join this conversation.