umbral_admin/models.rs
1//! Admin-owned models: user preferences and audit log.
2//!
3//! Registered via [`crate::AdminPlugin::models`] so they flow through the
4//! framework's migration engine like any other plugin's models. No raw
5//! `CREATE TABLE`, no `on_ready` bootstrap — the same path used for
6//! the admin LogEntry table.
7//!
8//! ## AdminUserPref
9//! One row per admin user. Created the first time a user lands on
10//! `GET /admin/api/prefs`. Holds theme, density, sidebar-collapsed
11//! state, and the serialized dashboard layout.
12//!
13//! ## AdminAuditLog
14//! One row per write operation (create / update / delete / bulk action).
15//! The actor is the `AuthUser` resolved from the session at call time;
16//! `diff_summary` is a short human description synthesized from context
17//! (no field-level diffing in v1).
18//!
19//! ## Why the model is `noedit`
20//! Every field on both models is marked `#[umbral(noedit)]` so the admin
21//! exposes them as read-only — users see preferences and audit history
22//! in the UI but cannot mutate them through the form path. Writes flow
23//! exclusively through this module's typed helpers.
24
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use umbral::orm::Model;
28
29// =========================================================================
30// AdminUserPref
31// =========================================================================
32
33/// Per-user admin preferences row.
34///
35/// One row per admin user, keyed by `user_id`. The framework cannot yet
36/// express a UNIQUE constraint via `#[derive(Model)]`, so the
37/// one-row-per-user invariant is enforced at the application layer in
38/// [`fetch_or_default`] + [`upsert`]: a fetch-then-save flow with
39/// last-write-wins semantics. When the macro grows `#[umbral(unique)]`,
40/// `user_id` gets the attribute and the race window closes.
41#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, Model)]
42#[umbral(display = "User preference", icon = "settings-2")]
43pub struct AdminUserPref {
44 pub id: i64,
45 /// FK to `auth_user` (typed FK at the Model level is a follow-on;
46 /// `i64` for now).
47 #[umbral(noedit)]
48 pub user_id: i64,
49 /// One of "light" | "dark" | "system".
50 #[umbral(noedit)]
51 pub theme: String,
52 /// One of "comfortable" | "compact".
53 #[umbral(noedit)]
54 pub density: String,
55 /// Whether the sidebar is collapsed to the icon rail.
56 #[umbral(noedit)]
57 pub sidebar_collapsed: bool,
58 /// Serialized `Vec<WidgetInstance>` JSON blob.
59 #[umbral(noedit)]
60 pub dashboard_layout: String,
61 /// gaps2 #11 — free-form JSON map of per-table UI state. Shape:
62 ///
63 /// ```jsonc
64 /// {
65 /// "tables": {
66 /// "product": {
67 /// "filters": { "status": "active" },
68 /// "search": "widget",
69 /// "sort": "-price",
70 /// "per_page": 50
71 /// }
72 /// }
73 /// }
74 /// ```
75 ///
76 /// `Option<String>` so existing rows (NULL after the migration's
77 /// ADD COLUMN) read as "no prefs yet" without a backfill pass.
78 /// The first time a user visits a changelist, their current
79 /// query string gets persisted; on a subsequent paramless visit,
80 /// the changelist handler 303-redirects to the saved URL shape.
81 /// Cross-tab / cross-device continuity for free.
82 #[umbral(noedit, widget = "code")]
83 pub preferences: Option<String>,
84 #[umbral(noedit)]
85 pub updated_at: DateTime<Utc>,
86}
87
88impl AdminUserPref {
89 /// Default prefs for a brand-new admin user. The struct is returned
90 /// with `id = 0` so a subsequent `.save()` becomes an INSERT.
91 pub fn default_for(user_id: i64) -> Self {
92 Self {
93 id: 0,
94 user_id,
95 theme: "dark".to_string(),
96 density: "comfortable".to_string(),
97 sidebar_collapsed: false,
98 dashboard_layout: "[]".to_string(),
99 preferences: None,
100 updated_at: Utc::now(),
101 }
102 }
103}
104
105/// gaps2 #11 — per-table changelist UI state. Persisted as a nested
106/// entry under `preferences.tables.<table>` in the JSON blob.
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct TablePref {
109 /// Map of `field_name → string-value` for active facet filters.
110 /// Empty map omits the `?filter_*=...` params on redirect.
111 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
112 pub filters: std::collections::HashMap<String, String>,
113 /// Current search term (becomes `?search=...`). Empty string is
114 /// dropped from the URL.
115 #[serde(default, skip_serializing_if = "String::is_empty")]
116 pub search: String,
117 /// Sort directive in `[-]col_name` shape — empty = no override
118 /// (falls through to the model's default ordering).
119 #[serde(default, skip_serializing_if = "String::is_empty")]
120 pub sort: String,
121 /// Page size override. `None` falls through to the configured
122 /// admin default. Stored as `u32` because some callers cast to
123 /// `usize` and some to `i64`; `u32` round-trips through both.
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub per_page: Option<u32>,
126 /// Hidden columns on this table. Round-2 follow-up to the
127 /// initial gaps2 #11 ship. Render path filters
128 /// `display_cols` against this list; the toggle endpoint
129 /// `POST /admin/{table}/columns/{column}/toggle` flips
130 /// membership and returns an HX-Trigger to refresh the table.
131 /// Empty vec = every column visible (the default).
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub hidden_cols: Vec<String>,
134}
135
136/// gaps2 #11 — read the persisted UI state for `(user_id, table)`.
137///
138/// Returns `None` when:
139/// - the user has no prefs row yet (NULL `preferences` column);
140/// - the JSON blob is present but missing `tables.<table>`;
141/// - the JSON blob is malformed (treated as "no prefs" rather than
142/// surfacing a parse error — the next write overwrites with a
143/// valid shape).
144pub async fn get_table_pref(user_id: i64, table: &str) -> Result<Option<TablePref>, sqlx::Error> {
145 let prefs = fetch_or_default(user_id).await?;
146 let Some(raw) = prefs.preferences.as_deref() else {
147 return Ok(None);
148 };
149 let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
150 return Ok(None);
151 };
152 let Some(table_obj) = root.get("tables").and_then(|t| t.get(table)) else {
153 return Ok(None);
154 };
155 let Ok(pref) = serde_json::from_value::<TablePref>(table_obj.clone()) else {
156 return Ok(None);
157 };
158 Ok(Some(pref))
159}
160
161/// gaps2 #11 — merge a new `TablePref` into `preferences.tables.<table>`.
162///
163/// Read-modify-write rather than a JSON_SET / json_replace SQL pass:
164/// the shape lives in user code, and the v1 single-tab usage doesn't
165/// race. When two tabs CAN race (the gap's `hx-trigger="change
166/// delay:500ms"` follow-up), the merge moves to the SQL layer; the
167/// in-memory merge here is forward-compatible because the JSON
168/// structure is the same either way.
169pub async fn set_table_pref(
170 user_id: i64,
171 table: &str,
172 pref: &TablePref,
173) -> Result<(), sqlx::Error> {
174 let existing = fetch_or_default(user_id).await?;
175 let mut root: serde_json::Value = existing
176 .preferences
177 .as_deref()
178 .and_then(|s| serde_json::from_str(s).ok())
179 .unwrap_or_else(|| serde_json::json!({}));
180 let pref_value = serde_json::to_value(pref).unwrap_or(serde_json::Value::Null);
181 root.as_object_mut()
182 .expect("root is always an object")
183 .entry("tables")
184 .or_insert_with(|| serde_json::json!({}))
185 .as_object_mut()
186 .expect("tables is always an object")
187 .insert(table.to_string(), pref_value);
188 let mut next = existing;
189 next.preferences = Some(root.to_string());
190 upsert(next).await?;
191 Ok(())
192}
193
194/// gaps2 #11 round 2 — read the "last viewed admin URL" for
195/// `user_id`. Used by the admin index handler to redirect
196/// `/admin/` → the user's last working changelist.
197///
198/// Returns `None` when no prefs row yet, when `preferences.last_path`
199/// is missing, or when the value isn't a string.
200pub async fn get_last_path(user_id: i64) -> Result<Option<String>, sqlx::Error> {
201 let prefs = fetch_or_default(user_id).await?;
202 let Some(raw) = prefs.preferences.as_deref() else {
203 return Ok(None);
204 };
205 let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
206 return Ok(None);
207 };
208 Ok(root
209 .get("last_path")
210 .and_then(|v| v.as_str())
211 .map(|s| s.to_string()))
212}
213
214/// gaps2 #11 round 2 — write `last_path` to `preferences.last_path`.
215/// Read-modify-write through the JSON blob, same pattern as
216/// `set_table_pref`.
217pub async fn set_last_path(user_id: i64, path: &str) -> Result<(), sqlx::Error> {
218 let existing = fetch_or_default(user_id).await?;
219 let mut root: serde_json::Value = existing
220 .preferences
221 .as_deref()
222 .and_then(|s| serde_json::from_str(s).ok())
223 .unwrap_or_else(|| serde_json::json!({}));
224 root.as_object_mut()
225 .expect("root is always an object")
226 .insert(
227 "last_path".to_string(),
228 serde_json::Value::String(path.to_string()),
229 );
230 let mut next = existing;
231 next.preferences = Some(root.to_string());
232 upsert(next).await?;
233 Ok(())
234}
235
236/// gaps2 #11 round 2 — read a saved widget-period override for
237/// `widget_key` on `preferences.dashboard.widget_periods.<key>`.
238///
239/// Returns `None` when no override is set. The dashboard's widget-
240/// data handler treats `None` as "fall through to the widget's
241/// registration-time `default_period`."
242pub async fn get_widget_period(
243 user_id: i64,
244 widget_key: &str,
245) -> Result<Option<String>, sqlx::Error> {
246 let prefs = fetch_or_default(user_id).await?;
247 let Some(raw) = prefs.preferences.as_deref() else {
248 return Ok(None);
249 };
250 let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
251 return Ok(None);
252 };
253 Ok(root
254 .get("dashboard")
255 .and_then(|d| d.get("widget_periods"))
256 .and_then(|p| p.get(widget_key))
257 .and_then(|v| v.as_str())
258 .map(|s| s.to_string()))
259}
260
261/// gaps2 #11 round 2 — persist a widget-period override at
262/// `preferences.dashboard.widget_periods.<widget_key>`. Same
263/// read-modify-write merge as `set_table_pref` / `set_last_path`.
264pub async fn set_widget_period(
265 user_id: i64,
266 widget_key: &str,
267 period: &str,
268) -> Result<(), sqlx::Error> {
269 let existing = fetch_or_default(user_id).await?;
270 let mut root: serde_json::Value = existing
271 .preferences
272 .as_deref()
273 .and_then(|s| serde_json::from_str(s).ok())
274 .unwrap_or_else(|| serde_json::json!({}));
275 root.as_object_mut()
276 .expect("root is always an object")
277 .entry("dashboard")
278 .or_insert_with(|| serde_json::json!({}))
279 .as_object_mut()
280 .expect("dashboard is always an object")
281 .entry("widget_periods")
282 .or_insert_with(|| serde_json::json!({}))
283 .as_object_mut()
284 .expect("widget_periods is always an object")
285 .insert(
286 widget_key.to_string(),
287 serde_json::Value::String(period.to_string()),
288 );
289 let mut next = existing;
290 next.preferences = Some(root.to_string());
291 upsert(next).await?;
292 Ok(())
293}
294
295/// Every saved filter value for one widget, from
296/// `preferences.dashboard.widget_filters.<widget_key>`.
297///
298/// Falls back to the legacy `widget_periods` map for the `period` key so the
299/// period a user picked before declarative filters existed survives the
300/// upgrade. Without the fallback their chip selection would silently reset.
301pub async fn get_widget_filters(
302 user_id: i64,
303 widget_key: &str,
304) -> Result<std::collections::HashMap<String, String>, sqlx::Error> {
305 let mut out = std::collections::HashMap::new();
306
307 if let Some(legacy) = get_widget_period(user_id, widget_key).await? {
308 out.insert("period".to_string(), legacy);
309 }
310
311 let prefs = fetch_or_default(user_id).await?;
312 let Some(raw) = prefs.preferences.as_deref() else {
313 return Ok(out);
314 };
315 let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
316 return Ok(out);
317 };
318 if let Some(map) = root
319 .get("dashboard")
320 .and_then(|d| d.get("widget_filters"))
321 .and_then(|f| f.get(widget_key))
322 .and_then(|v| v.as_object())
323 {
324 for (k, v) in map {
325 if let Some(s) = v.as_str() {
326 out.insert(k.clone(), s.to_string());
327 }
328 }
329 }
330 Ok(out)
331}
332
333/// Persist one filter value at
334/// `preferences.dashboard.widget_filters.<widget_key>.<filter_key>`.
335///
336/// Same read-modify-write merge as [`set_widget_period`]. A filter the user
337/// picks is sticky across reloads, tabs and devices — the dashboard is a tool
338/// people re-open, and re-picking "status = paid" every morning is the kind of
339/// papercut that makes a control panel feel disposable.
340pub async fn set_widget_filter(
341 user_id: i64,
342 widget_key: &str,
343 filter_key: &str,
344 value: &str,
345) -> Result<(), sqlx::Error> {
346 let existing = fetch_or_default(user_id).await?;
347 let mut root: serde_json::Value = existing
348 .preferences
349 .as_deref()
350 .and_then(|s| serde_json::from_str(s).ok())
351 .unwrap_or_else(|| serde_json::json!({}));
352 root.as_object_mut()
353 .expect("root is always an object")
354 .entry("dashboard")
355 .or_insert_with(|| serde_json::json!({}))
356 .as_object_mut()
357 .expect("dashboard is always an object")
358 .entry("widget_filters")
359 .or_insert_with(|| serde_json::json!({}))
360 .as_object_mut()
361 .expect("widget_filters is always an object")
362 .entry(widget_key.to_string())
363 .or_insert_with(|| serde_json::json!({}))
364 .as_object_mut()
365 .expect("per-widget filter map is always an object")
366 .insert(
367 filter_key.to_string(),
368 serde_json::Value::String(value.to_string()),
369 );
370 let mut next = existing;
371 next.preferences = Some(root.to_string());
372 upsert(next).await?;
373 Ok(())
374}
375
376/// gaps2 #11 round 2 — flip a column's visibility on
377/// `preferences.tables.<table>.hidden_cols`. Idempotent toggle:
378/// already-hidden → visible, already-visible → hidden. Returns
379/// the new visibility (`true` = now visible, `false` = now hidden)
380/// so the caller can emit a precise HX-Trigger payload.
381pub async fn toggle_table_col(
382 user_id: i64,
383 table: &str,
384 column: &str,
385) -> Result<bool, sqlx::Error> {
386 let mut pref = get_table_pref(user_id, table).await?.unwrap_or_default();
387 let now_visible = if let Some(pos) = pref.hidden_cols.iter().position(|c| c == column) {
388 pref.hidden_cols.remove(pos);
389 true
390 } else {
391 pref.hidden_cols.push(column.to_string());
392 false
393 };
394 set_table_pref(user_id, table, &pref).await?;
395 Ok(now_visible)
396}
397
398/// Fetch the prefs row for `user_id`, or return a struct filled with
399/// defaults (the row is **not** inserted; the caller decides whether to
400/// persist). `id == 0` distinguishes the unsaved-default case.
401pub async fn fetch_or_default(user_id: i64) -> Result<AdminUserPref, sqlx::Error> {
402 let existing = AdminUserPref::objects()
403 .filter(admin_user_pref::USER_ID.eq(user_id))
404 .first()
405 .await?;
406 Ok(existing.unwrap_or_else(|| AdminUserPref::default_for(user_id)))
407}
408
409/// Insert or update the prefs row.
410///
411/// Uses [`umbral::orm::Manager::save`] which dispatches by primary key:
412/// `id == 0` → INSERT, otherwise UPDATE. The caller is responsible for
413/// loading the row via [`fetch_or_default`] before mutating + persisting
414/// so the `id` round-trips correctly.
415pub async fn upsert(prefs: AdminUserPref) -> Result<AdminUserPref, sqlx::Error> {
416 let mut prefs = prefs;
417 prefs.updated_at = Utc::now();
418 AdminUserPref::objects()
419 .save(prefs)
420 .await
421 .map_err(|e| match e {
422 umbral::orm::SaveError::Write(umbral::orm::WriteError::Sqlx(e)) => e,
423 other => sqlx::Error::Protocol(other.to_string()),
424 })
425}
426
427// =========================================================================
428// AdminAuditLog
429// =========================================================================
430
431/// One entry in the admin audit trail.
432///
433/// Append-only via [`log`]. The admin surfaces the table read-only;
434/// every column carries `#[umbral(noedit)]` so the form path can't mutate
435/// rows even if someone navigates directly to the edit URL.
436#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, Model)]
437#[umbral(display = "Audit log", icon = "scroll-text")]
438pub struct AdminAuditLog {
439 pub id: i64,
440 /// FK to `auth_user`.
441 #[umbral(noedit)]
442 pub actor_user_id: i64,
443 /// One of: `"create"` | `"update"` | `"delete"` | `"action:<key>"`.
444 #[umbral(noedit)]
445 pub action: String,
446 /// SQL table name the operation touched.
447 #[umbral(noedit)]
448 pub model: String,
449 /// PK of the affected row, as TEXT, NULL for bulk / non-row operations (gaps3 #59).
450 ///
451 /// Text, not `i64`, for the same reason the session table stores its user id as text:
452 /// a model's primary key may be an `i64`, a `String` or a `Uuid`. As an INTEGER this
453 /// column could not address a non-i64 row at all — the object-history page 400'd for
454 /// every row of such a model, and every admin write logged `object_id = NULL`,
455 /// including the password-change audit. An audit trail that cannot name the object it
456 /// audited is not an audit trail.
457 #[umbral(noedit)]
458 pub object_id: Option<String>,
459 /// Short human description, e.g. `"created Post #42"`.
460 #[umbral(noedit)]
461 pub diff_summary: String,
462 #[umbral(noedit)]
463 pub created_at: DateTime<Utc>,
464}
465
466/// Append one audit entry. Fire-and-forget: errors are logged but never
467/// surfaced to the caller, so a CRUD handler that succeeds at its real
468/// work isn't undone by an audit-write hiccup.
469pub async fn log(
470 actor_user_id: i64,
471 action: &str,
472 model: &str,
473 object_id: Option<String>,
474 diff_summary: &str,
475) {
476 let entry = AdminAuditLog {
477 id: 0,
478 actor_user_id,
479 action: action.to_string(),
480 model: model.to_string(),
481 object_id,
482 diff_summary: diff_summary.to_string(),
483 created_at: Utc::now(),
484 };
485 if let Err(e) = AdminAuditLog::objects().save(entry).await {
486 tracing::error!(error = %e, "admin: audit log insert failed");
487 }
488}
489
490/// Fetch the last `limit` audit entries for one object, newest first.
491/// Returned as template-friendly [`AuditEntry`] values (timestamps
492/// formatted as strings) for direct rendering by minijinja.
493pub async fn audit_for_object(
494 model: &str,
495 object_id: &str,
496 limit: u64,
497) -> Result<Vec<AuditEntry>, sqlx::Error> {
498 let rows = AdminAuditLog::objects()
499 .filter(admin_audit_log::MODEL.eq(model.to_string()))
500 .filter(admin_audit_log::OBJECT_ID.eq(object_id.to_string()))
501 .order_by(admin_audit_log::CREATED_AT.desc())
502 .limit(limit)
503 .fetch()
504 .await?;
505 Ok(rows.into_iter().map(AuditEntry::from).collect())
506}
507
508/// Template-friendly audit entry — `created_at` rendered as RFC 3339
509/// for minijinja, which has no `DateTime` codec.
510#[derive(Debug, Clone, Serialize)]
511pub struct AuditEntry {
512 pub id: i64,
513 pub actor_user_id: i64,
514 pub action: String,
515 pub model: String,
516 pub object_id: Option<String>,
517 pub diff_summary: String,
518 pub created_at: String,
519}
520
521impl From<AdminAuditLog> for AuditEntry {
522 fn from(row: AdminAuditLog) -> Self {
523 Self {
524 id: row.id,
525 actor_user_id: row.actor_user_id,
526 action: row.action,
527 model: row.model,
528 object_id: row.object_id,
529 diff_summary: row.diff_summary,
530 created_at: row.created_at.to_rfc3339(),
531 }
532 }
533}
534
535// =========================================================================
536// Test-fixture helper
537// =========================================================================
538
539/// Create the admin tables on a raw pool, bypassing the migration engine.
540///
541/// Production code never calls this — `AdminPlugin::models()` exposes the
542/// two models to the framework and the migration engine creates the
543/// schema on `migrate run` like everything else. The helper exists for
544/// integration tests that boot `App::builder()` without running
545/// `umbral::migrate::run()` (creating migration files inside `target/`
546/// every test run is the wrong tradeoff).
547///
548/// Idempotent — `CREATE TABLE IF NOT EXISTS` so repeated calls within a
549/// single test process are safe.
550#[doc(hidden)]
551pub async fn ensure_tables_for_tests(pool: &sqlx::SqlitePool) -> Result<(), sqlx::Error> {
552 sqlx::query(
553 "CREATE TABLE IF NOT EXISTS admin_user_pref (
554 id INTEGER PRIMARY KEY AUTOINCREMENT,
555 user_id INTEGER NOT NULL,
556 theme TEXT NOT NULL DEFAULT 'dark',
557 density TEXT NOT NULL DEFAULT 'comfortable',
558 sidebar_collapsed INTEGER NOT NULL DEFAULT 0,
559 dashboard_layout TEXT NOT NULL DEFAULT '[]',
560 preferences TEXT,
561 updated_at TEXT NOT NULL
562 )",
563 )
564 .execute(pool)
565 .await?;
566
567 sqlx::query(
568 "CREATE TABLE IF NOT EXISTS admin_audit_log (
569 id INTEGER PRIMARY KEY AUTOINCREMENT,
570 actor_user_id INTEGER NOT NULL,
571 action TEXT NOT NULL,
572 model TEXT NOT NULL,
573 object_id TEXT,
574 diff_summary TEXT NOT NULL,
575 created_at TEXT NOT NULL
576 )",
577 )
578 .execute(pool)
579 .await?;
580
581 Ok(())
582}