umbral-admin 0.0.11

Auto-generated CRUD admin UI for umbral models.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
//! Admin-owned models: user preferences and audit log.
//!
//! Registered via [`crate::AdminPlugin::models`] so they flow through the
//! framework's migration engine like any other plugin's models. No raw
//! `CREATE TABLE`, no `on_ready` bootstrap — the same path used for
//! the admin LogEntry table.
//!
//! ## AdminUserPref
//! One row per admin user. Created the first time a user lands on
//! `GET /admin/api/prefs`. Holds theme, density, sidebar-collapsed
//! state, and the serialized dashboard layout.
//!
//! ## AdminAuditLog
//! One row per write operation (create / update / delete / bulk action).
//! The actor is the `AuthUser` resolved from the session at call time;
//! `diff_summary` is a short human description synthesized from context
//! (no field-level diffing in v1).
//!
//! ## Why the model is `noedit`
//! Every field on both models is marked `#[umbral(noedit)]` so the admin
//! exposes them as read-only — users see preferences and audit history
//! in the UI but cannot mutate them through the form path. Writes flow
//! exclusively through this module's typed helpers.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use umbral::orm::Model;

// =========================================================================
// AdminUserPref
// =========================================================================

/// Per-user admin preferences row.
///
/// One row per admin user, keyed by `user_id`. The framework cannot yet
/// express a UNIQUE constraint via `#[derive(Model)]`, so the
/// one-row-per-user invariant is enforced at the application layer in
/// [`fetch_or_default`] + [`upsert`]: a fetch-then-save flow with
/// last-write-wins semantics. When the macro grows `#[umbral(unique)]`,
/// `user_id` gets the attribute and the race window closes.
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, Model)]
#[umbral(display = "User preference", icon = "settings-2")]
pub struct AdminUserPref {
    pub id: i64,
    /// FK to `auth_user` (typed FK at the Model level is a follow-on;
    /// `i64` for now).
    #[umbral(noedit)]
    pub user_id: i64,
    /// One of "light" | "dark" | "system".
    #[umbral(noedit)]
    pub theme: String,
    /// One of "comfortable" | "compact".
    #[umbral(noedit)]
    pub density: String,
    /// Whether the sidebar is collapsed to the icon rail.
    #[umbral(noedit)]
    pub sidebar_collapsed: bool,
    /// Serialized `Vec<WidgetInstance>` JSON blob.
    #[umbral(noedit)]
    pub dashboard_layout: String,
    /// gaps2 #11 — free-form JSON map of per-table UI state. Shape:
    ///
    /// ```jsonc
    /// {
    ///   "tables": {
    ///     "product": {
    ///       "filters":  { "status": "active" },
    ///       "search":   "widget",
    ///       "sort":     "-price",
    ///       "per_page": 50
    ///     }
    ///   }
    /// }
    /// ```
    ///
    /// `Option<String>` so existing rows (NULL after the migration's
    /// ADD COLUMN) read as "no prefs yet" without a backfill pass.
    /// The first time a user visits a changelist, their current
    /// query string gets persisted; on a subsequent paramless visit,
    /// the changelist handler 303-redirects to the saved URL shape.
    /// Cross-tab / cross-device continuity for free.
    #[umbral(noedit, widget = "code")]
    pub preferences: Option<String>,
    #[umbral(noedit)]
    pub updated_at: DateTime<Utc>,
}

impl AdminUserPref {
    /// Default prefs for a brand-new admin user. The struct is returned
    /// with `id = 0` so a subsequent `.save()` becomes an INSERT.
    pub fn default_for(user_id: i64) -> Self {
        Self {
            id: 0,
            user_id,
            theme: "dark".to_string(),
            density: "comfortable".to_string(),
            sidebar_collapsed: false,
            dashboard_layout: "[]".to_string(),
            preferences: None,
            updated_at: Utc::now(),
        }
    }
}

/// gaps2 #11 — per-table changelist UI state. Persisted as a nested
/// entry under `preferences.tables.<table>` in the JSON blob.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TablePref {
    /// Map of `field_name → string-value` for active facet filters.
    /// Empty map omits the `?filter_*=...` params on redirect.
    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
    pub filters: std::collections::HashMap<String, String>,
    /// Current search term (becomes `?search=...`). Empty string is
    /// dropped from the URL.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub search: String,
    /// Sort directive in `[-]col_name` shape — empty = no override
    /// (falls through to the model's default ordering).
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub sort: String,
    /// Page size override. `None` falls through to the configured
    /// admin default. Stored as `u32` because some callers cast to
    /// `usize` and some to `i64`; `u32` round-trips through both.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub per_page: Option<u32>,
    /// Hidden columns on this table. Round-2 follow-up to the
    /// initial gaps2 #11 ship. Render path filters
    /// `display_cols` against this list; the toggle endpoint
    /// `POST /admin/{table}/columns/{column}/toggle` flips
    /// membership and returns an HX-Trigger to refresh the table.
    /// Empty vec = every column visible (the default).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hidden_cols: Vec<String>,
}

/// gaps2 #11 — read the persisted UI state for `(user_id, table)`.
///
/// Returns `None` when:
/// - the user has no prefs row yet (NULL `preferences` column);
/// - the JSON blob is present but missing `tables.<table>`;
/// - the JSON blob is malformed (treated as "no prefs" rather than
///   surfacing a parse error — the next write overwrites with a
///   valid shape).
pub async fn get_table_pref(user_id: i64, table: &str) -> Result<Option<TablePref>, sqlx::Error> {
    let prefs = fetch_or_default(user_id).await?;
    let Some(raw) = prefs.preferences.as_deref() else {
        return Ok(None);
    };
    let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
        return Ok(None);
    };
    let Some(table_obj) = root.get("tables").and_then(|t| t.get(table)) else {
        return Ok(None);
    };
    let Ok(pref) = serde_json::from_value::<TablePref>(table_obj.clone()) else {
        return Ok(None);
    };
    Ok(Some(pref))
}

/// gaps2 #11 — merge a new `TablePref` into `preferences.tables.<table>`.
///
/// Read-modify-write rather than a JSON_SET / json_replace SQL pass:
/// the shape lives in user code, and the v1 single-tab usage doesn't
/// race. When two tabs CAN race (the gap's `hx-trigger="change
/// delay:500ms"` follow-up), the merge moves to the SQL layer; the
/// in-memory merge here is forward-compatible because the JSON
/// structure is the same either way.
pub async fn set_table_pref(
    user_id: i64,
    table: &str,
    pref: &TablePref,
) -> Result<(), sqlx::Error> {
    let existing = fetch_or_default(user_id).await?;
    let mut root: serde_json::Value = existing
        .preferences
        .as_deref()
        .and_then(|s| serde_json::from_str(s).ok())
        .unwrap_or_else(|| serde_json::json!({}));
    let pref_value = serde_json::to_value(pref).unwrap_or(serde_json::Value::Null);
    root.as_object_mut()
        .expect("root is always an object")
        .entry("tables")
        .or_insert_with(|| serde_json::json!({}))
        .as_object_mut()
        .expect("tables is always an object")
        .insert(table.to_string(), pref_value);
    let mut next = existing;
    next.preferences = Some(root.to_string());
    upsert(next).await?;
    Ok(())
}

/// gaps2 #11 round 2 — read the "last viewed admin URL" for
/// `user_id`. Used by the admin index handler to redirect
/// `/admin/` → the user's last working changelist.
///
/// Returns `None` when no prefs row yet, when `preferences.last_path`
/// is missing, or when the value isn't a string.
pub async fn get_last_path(user_id: i64) -> Result<Option<String>, sqlx::Error> {
    let prefs = fetch_or_default(user_id).await?;
    let Some(raw) = prefs.preferences.as_deref() else {
        return Ok(None);
    };
    let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
        return Ok(None);
    };
    Ok(root
        .get("last_path")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string()))
}

/// gaps2 #11 round 2 — write `last_path` to `preferences.last_path`.
/// Read-modify-write through the JSON blob, same pattern as
/// `set_table_pref`.
pub async fn set_last_path(user_id: i64, path: &str) -> Result<(), sqlx::Error> {
    let existing = fetch_or_default(user_id).await?;
    let mut root: serde_json::Value = existing
        .preferences
        .as_deref()
        .and_then(|s| serde_json::from_str(s).ok())
        .unwrap_or_else(|| serde_json::json!({}));
    root.as_object_mut()
        .expect("root is always an object")
        .insert(
            "last_path".to_string(),
            serde_json::Value::String(path.to_string()),
        );
    let mut next = existing;
    next.preferences = Some(root.to_string());
    upsert(next).await?;
    Ok(())
}

/// gaps2 #11 round 2 — read a saved widget-period override for
/// `widget_key` on `preferences.dashboard.widget_periods.<key>`.
///
/// Returns `None` when no override is set. The dashboard's widget-
/// data handler treats `None` as "fall through to the widget's
/// registration-time `default_period`."
pub async fn get_widget_period(
    user_id: i64,
    widget_key: &str,
) -> Result<Option<String>, sqlx::Error> {
    let prefs = fetch_or_default(user_id).await?;
    let Some(raw) = prefs.preferences.as_deref() else {
        return Ok(None);
    };
    let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
        return Ok(None);
    };
    Ok(root
        .get("dashboard")
        .and_then(|d| d.get("widget_periods"))
        .and_then(|p| p.get(widget_key))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string()))
}

/// gaps2 #11 round 2 — persist a widget-period override at
/// `preferences.dashboard.widget_periods.<widget_key>`. Same
/// read-modify-write merge as `set_table_pref` / `set_last_path`.
pub async fn set_widget_period(
    user_id: i64,
    widget_key: &str,
    period: &str,
) -> Result<(), sqlx::Error> {
    let existing = fetch_or_default(user_id).await?;
    let mut root: serde_json::Value = existing
        .preferences
        .as_deref()
        .and_then(|s| serde_json::from_str(s).ok())
        .unwrap_or_else(|| serde_json::json!({}));
    root.as_object_mut()
        .expect("root is always an object")
        .entry("dashboard")
        .or_insert_with(|| serde_json::json!({}))
        .as_object_mut()
        .expect("dashboard is always an object")
        .entry("widget_periods")
        .or_insert_with(|| serde_json::json!({}))
        .as_object_mut()
        .expect("widget_periods is always an object")
        .insert(
            widget_key.to_string(),
            serde_json::Value::String(period.to_string()),
        );
    let mut next = existing;
    next.preferences = Some(root.to_string());
    upsert(next).await?;
    Ok(())
}

/// Every saved filter value for one widget, from
/// `preferences.dashboard.widget_filters.<widget_key>`.
///
/// Falls back to the legacy `widget_periods` map for the `period` key so the
/// period a user picked before declarative filters existed survives the
/// upgrade. Without the fallback their chip selection would silently reset.
pub async fn get_widget_filters(
    user_id: i64,
    widget_key: &str,
) -> Result<std::collections::HashMap<String, String>, sqlx::Error> {
    let mut out = std::collections::HashMap::new();

    if let Some(legacy) = get_widget_period(user_id, widget_key).await? {
        out.insert("period".to_string(), legacy);
    }

    let prefs = fetch_or_default(user_id).await?;
    let Some(raw) = prefs.preferences.as_deref() else {
        return Ok(out);
    };
    let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
        return Ok(out);
    };
    if let Some(map) = root
        .get("dashboard")
        .and_then(|d| d.get("widget_filters"))
        .and_then(|f| f.get(widget_key))
        .and_then(|v| v.as_object())
    {
        for (k, v) in map {
            if let Some(s) = v.as_str() {
                out.insert(k.clone(), s.to_string());
            }
        }
    }
    Ok(out)
}

/// Persist one filter value at
/// `preferences.dashboard.widget_filters.<widget_key>.<filter_key>`.
///
/// Same read-modify-write merge as [`set_widget_period`]. A filter the user
/// picks is sticky across reloads, tabs and devices — the dashboard is a tool
/// people re-open, and re-picking "status = paid" every morning is the kind of
/// papercut that makes a control panel feel disposable.
pub async fn set_widget_filter(
    user_id: i64,
    widget_key: &str,
    filter_key: &str,
    value: &str,
) -> Result<(), sqlx::Error> {
    let existing = fetch_or_default(user_id).await?;
    let mut root: serde_json::Value = existing
        .preferences
        .as_deref()
        .and_then(|s| serde_json::from_str(s).ok())
        .unwrap_or_else(|| serde_json::json!({}));
    root.as_object_mut()
        .expect("root is always an object")
        .entry("dashboard")
        .or_insert_with(|| serde_json::json!({}))
        .as_object_mut()
        .expect("dashboard is always an object")
        .entry("widget_filters")
        .or_insert_with(|| serde_json::json!({}))
        .as_object_mut()
        .expect("widget_filters is always an object")
        .entry(widget_key.to_string())
        .or_insert_with(|| serde_json::json!({}))
        .as_object_mut()
        .expect("per-widget filter map is always an object")
        .insert(
            filter_key.to_string(),
            serde_json::Value::String(value.to_string()),
        );
    let mut next = existing;
    next.preferences = Some(root.to_string());
    upsert(next).await?;
    Ok(())
}

/// gaps2 #11 round 2 — flip a column's visibility on
/// `preferences.tables.<table>.hidden_cols`. Idempotent toggle:
/// already-hidden → visible, already-visible → hidden. Returns
/// the new visibility (`true` = now visible, `false` = now hidden)
/// so the caller can emit a precise HX-Trigger payload.
pub async fn toggle_table_col(
    user_id: i64,
    table: &str,
    column: &str,
) -> Result<bool, sqlx::Error> {
    let mut pref = get_table_pref(user_id, table).await?.unwrap_or_default();
    let now_visible = if let Some(pos) = pref.hidden_cols.iter().position(|c| c == column) {
        pref.hidden_cols.remove(pos);
        true
    } else {
        pref.hidden_cols.push(column.to_string());
        false
    };
    set_table_pref(user_id, table, &pref).await?;
    Ok(now_visible)
}

/// Fetch the prefs row for `user_id`, or return a struct filled with
/// defaults (the row is **not** inserted; the caller decides whether to
/// persist). `id == 0` distinguishes the unsaved-default case.
pub async fn fetch_or_default(user_id: i64) -> Result<AdminUserPref, sqlx::Error> {
    let existing = AdminUserPref::objects()
        .filter(admin_user_pref::USER_ID.eq(user_id))
        .first()
        .await?;
    Ok(existing.unwrap_or_else(|| AdminUserPref::default_for(user_id)))
}

/// Insert or update the prefs row.
///
/// Uses [`umbral::orm::Manager::save`] which dispatches by primary key:
/// `id == 0` → INSERT, otherwise UPDATE. The caller is responsible for
/// loading the row via [`fetch_or_default`] before mutating + persisting
/// so the `id` round-trips correctly.
pub async fn upsert(prefs: AdminUserPref) -> Result<AdminUserPref, sqlx::Error> {
    let mut prefs = prefs;
    prefs.updated_at = Utc::now();
    AdminUserPref::objects()
        .save(prefs)
        .await
        .map_err(|e| match e {
            umbral::orm::SaveError::Write(umbral::orm::WriteError::Sqlx(e)) => e,
            other => sqlx::Error::Protocol(other.to_string()),
        })
}

// =========================================================================
// AdminAuditLog
// =========================================================================

/// One entry in the admin audit trail.
///
/// Append-only via [`log`]. The admin surfaces the table read-only;
/// every column carries `#[umbral(noedit)]` so the form path can't mutate
/// rows even if someone navigates directly to the edit URL.
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, Model)]
#[umbral(display = "Audit log", icon = "scroll-text")]
pub struct AdminAuditLog {
    pub id: i64,
    /// FK to `auth_user`.
    #[umbral(noedit)]
    pub actor_user_id: i64,
    /// One of: `"create"` | `"update"` | `"delete"` | `"action:<key>"`.
    #[umbral(noedit)]
    pub action: String,
    /// SQL table name the operation touched.
    #[umbral(noedit)]
    pub model: String,
    /// PK of the affected row, as TEXT, NULL for bulk / non-row operations (gaps3 #59).
    ///
    /// Text, not `i64`, for the same reason the session table stores its user id as text:
    /// a model's primary key may be an `i64`, a `String` or a `Uuid`. As an INTEGER this
    /// column could not address a non-i64 row at all — the object-history page 400'd for
    /// every row of such a model, and every admin write logged `object_id = NULL`,
    /// including the password-change audit. An audit trail that cannot name the object it
    /// audited is not an audit trail.
    #[umbral(noedit)]
    pub object_id: Option<String>,
    /// Short human description, e.g. `"created Post #42"`.
    #[umbral(noedit)]
    pub diff_summary: String,
    #[umbral(noedit)]
    pub created_at: DateTime<Utc>,
}

/// Append one audit entry. Fire-and-forget: errors are logged but never
/// surfaced to the caller, so a CRUD handler that succeeds at its real
/// work isn't undone by an audit-write hiccup.
pub async fn log(
    actor_user_id: i64,
    action: &str,
    model: &str,
    object_id: Option<String>,
    diff_summary: &str,
) {
    let entry = AdminAuditLog {
        id: 0,
        actor_user_id,
        action: action.to_string(),
        model: model.to_string(),
        object_id,
        diff_summary: diff_summary.to_string(),
        created_at: Utc::now(),
    };
    if let Err(e) = AdminAuditLog::objects().save(entry).await {
        tracing::error!(error = %e, "admin: audit log insert failed");
    }
}

/// Fetch the last `limit` audit entries for one object, newest first.
/// Returned as template-friendly [`AuditEntry`] values (timestamps
/// formatted as strings) for direct rendering by minijinja.
pub async fn audit_for_object(
    model: &str,
    object_id: &str,
    limit: u64,
) -> Result<Vec<AuditEntry>, sqlx::Error> {
    let rows = AdminAuditLog::objects()
        .filter(admin_audit_log::MODEL.eq(model.to_string()))
        .filter(admin_audit_log::OBJECT_ID.eq(object_id.to_string()))
        .order_by(admin_audit_log::CREATED_AT.desc())
        .limit(limit)
        .fetch()
        .await?;
    Ok(rows.into_iter().map(AuditEntry::from).collect())
}

/// Template-friendly audit entry — `created_at` rendered as RFC 3339
/// for minijinja, which has no `DateTime` codec.
#[derive(Debug, Clone, Serialize)]
pub struct AuditEntry {
    pub id: i64,
    pub actor_user_id: i64,
    pub action: String,
    pub model: String,
    pub object_id: Option<String>,
    pub diff_summary: String,
    pub created_at: String,
}

impl From<AdminAuditLog> for AuditEntry {
    fn from(row: AdminAuditLog) -> Self {
        Self {
            id: row.id,
            actor_user_id: row.actor_user_id,
            action: row.action,
            model: row.model,
            object_id: row.object_id,
            diff_summary: row.diff_summary,
            created_at: row.created_at.to_rfc3339(),
        }
    }
}

// =========================================================================
// Test-fixture helper
// =========================================================================

/// Create the admin tables on a raw pool, bypassing the migration engine.
///
/// Production code never calls this — `AdminPlugin::models()` exposes the
/// two models to the framework and the migration engine creates the
/// schema on `migrate run` like everything else. The helper exists for
/// integration tests that boot `App::builder()` without running
/// `umbral::migrate::run()` (creating migration files inside `target/`
/// every test run is the wrong tradeoff).
///
/// Idempotent — `CREATE TABLE IF NOT EXISTS` so repeated calls within a
/// single test process are safe.
#[doc(hidden)]
pub async fn ensure_tables_for_tests(pool: &sqlx::SqlitePool) -> Result<(), sqlx::Error> {
    sqlx::query(
        "CREATE TABLE IF NOT EXISTS admin_user_pref (
            id                INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id           INTEGER NOT NULL,
            theme             TEXT    NOT NULL DEFAULT 'dark',
            density           TEXT    NOT NULL DEFAULT 'comfortable',
            sidebar_collapsed INTEGER NOT NULL DEFAULT 0,
            dashboard_layout  TEXT    NOT NULL DEFAULT '[]',
            preferences       TEXT,
            updated_at        TEXT    NOT NULL
        )",
    )
    .execute(pool)
    .await?;

    sqlx::query(
        "CREATE TABLE IF NOT EXISTS admin_audit_log (
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
            actor_user_id INTEGER NOT NULL,
            action        TEXT    NOT NULL,
            model         TEXT    NOT NULL,
            object_id     TEXT,
            diff_summary  TEXT    NOT NULL,
            created_at    TEXT    NOT NULL
        )",
    )
    .execute(pool)
    .await?;

    Ok(())
}