Skip to main content

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}