Skip to main content

rustio_admin/admin/
bulk.rs

1//! Public surface for project-defined bulk-action dispatch.
2//!
3//! The framework owns the metadata declaration ([`super::BulkAction`])
4//! and the rendering / confirmation flow; *projects* own what each
5//! action actually does. This module carries the three types that
6//! cross the public boundary between framework and project:
7//!
8//!   - [`BulkActionContext`] — what the framework hands to the
9//!     project's handler (actor, correlation-id, client IP). Narrow
10//!     by design; `#[non_exhaustive]` keeps it SemVer-safe.
11//!   - [`BulkActionResult`] — what the project hands back. Carries
12//!     a succeeded count, an optional per-id failure list, and an
13//!     operator-facing summary line.
14//!   - [`BulkActionFailure`] — one row that failed inside an
15//!     otherwise-successful batch.
16//!
17//! The dispatcher itself is [`super::ModelAdmin::execute_bulk_action`].
18//! See its docstring for the contract; see `DESIGN_CHROME.md` for the
19//! bulk-bar's visual conventions.
20//!
21//! The framework emits **one** `audit::record` row per submission
22//! after the project's handler returns. Projects don't need to call
23//! `audit::record` themselves for the dispatch envelope; any
24//! business-level audit emissions inside the action body are still
25//! the project's call.
26
27use crate::auth::Identity;
28
29// public:
30/// Per-request context the framework passes into project-side
31/// [`super::ModelAdmin::execute_bulk_action`] implementations.
32///
33/// Narrower than a generic "request context" on purpose. The
34/// framework promises three facts at the dispatch boundary:
35///
36///   - `actor` — who initiated this action. Used by projects for
37///     per-actor authorisation refinements, per-row audit
38///     emission, and "edited_by"-shaped column writes.
39///   - `correlation_id` — the per-request UUID. Projects that emit
40///     their own audit rows should reuse it so the history chain
41///     stays linked through their work.
42///   - `ip_address` — the client IP resolved from `x-forwarded-for`
43///     / `x-real-ip` headers, when present.
44///
45/// `#[non_exhaustive]` so future additions (e.g. a per-request
46/// transaction handle, a `ProgressHandle` for streaming progress)
47/// stay SemVer-safe.
48#[non_exhaustive]
49pub struct BulkActionContext<'a> {
50    /// The signed-in operator who initiated the bulk dispatch.
51    pub actor: &'a Identity,
52    /// Per-request correlation id from the R0 middleware. `None`
53    /// only on request paths that don't run the correlation
54    /// middleware (none in the framework today; reserved for
55    /// future custom mounts).
56    pub correlation_id: Option<&'a str>,
57    /// Client IP resolved from request headers. `None` when the
58    /// framework couldn't determine it (no proxy headers, no
59    /// connecting peer info available).
60    pub ip_address: Option<&'a str>,
61}
62
63impl<'a> BulkActionContext<'a> {
64    /// Construct a minimal context — actor-only. Future fields
65    /// default to `None`. Useful for project-side unit tests of
66    /// `execute_bulk_action` that don't want to fabricate a
67    /// correlation id or IP.
68    pub fn new(actor: &'a Identity) -> Self {
69        Self {
70            actor,
71            correlation_id: None,
72            ip_address: None,
73        }
74    }
75}
76
77// public:
78/// Outcome of a project-defined bulk action.
79///
80/// Two channels:
81///
82///   - The action *itself* failed (unknown action name, project
83///     code panicked into a `Result::Err`, DB connection lost).
84///     The dispatcher returns `Err(...)` from
85///     [`super::ModelAdmin::execute_bulk_action`]; the framework
86///     surfaces it as a 4xx / 5xx page.
87///   - The action ran but *some rows* failed (one of three loans
88///     was already in the target state, an FK constraint
89///     rejected one of the writes). The dispatcher returns
90///     `Ok(BulkActionResult)` with `failed` populated; the
91///     framework emits a partial-success audit row and the
92///     operator sees a per-id failure summary.
93///
94/// `#[non_exhaustive]` so future additions (e.g. a `warnings`
95/// channel, a `progress_handle` for streaming) stay SemVer-safe.
96#[non_exhaustive]
97#[derive(Debug, Clone, Default)]
98pub struct BulkActionResult {
99    /// Number of rows the project successfully applied the action
100    /// to.
101    pub succeeded: usize,
102    /// Per-row failure list. Empty for a clean run; one entry per
103    /// row the project tried and couldn't complete.
104    pub failed: Vec<BulkActionFailure>,
105    /// Operator-facing summary line. `None` lets the framework
106    /// fall back to its default rendering
107    /// (`"<label>: <succeeded> of <total>"`).
108    pub message: Option<String>,
109}
110
111impl BulkActionResult {
112    /// All `succeeded` rows applied cleanly; no failures.
113    pub fn ok(succeeded: usize) -> Self {
114        Self {
115            succeeded,
116            failed: Vec::new(),
117            message: None,
118        }
119    }
120
121    /// Mixed outcome — pair the survivor count with the per-id
122    /// failure list.
123    pub fn partial(succeeded: usize, failed: Vec<BulkActionFailure>) -> Self {
124        Self {
125            succeeded,
126            failed,
127            message: None,
128        }
129    }
130
131    /// Total rows the project attempted: `succeeded + failed.len()`.
132    pub fn total(&self) -> usize {
133        self.succeeded + self.failed.len()
134    }
135
136    /// Attach an operator-facing summary line. Builder-style for
137    /// terse construction:
138    ///
139    /// ```ignore
140    /// BulkActionResult::ok(12).with_message("Marked 12 loans overdue")
141    /// ```
142    pub fn with_message(mut self, message: impl Into<String>) -> Self {
143        self.message = Some(message.into());
144        self
145    }
146}
147
148// public:
149/// One row that failed inside an otherwise-successful bulk batch.
150///
151/// `#[non_exhaustive]` so future additions (e.g. a structured
152/// `ErrorKind` enum, a retryable hint) stay SemVer-safe.
153#[non_exhaustive]
154#[derive(Debug, Clone)]
155pub struct BulkActionFailure {
156    /// The row id the project couldn't apply the action to.
157    pub id: i64,
158    /// Operator-facing reason. Shown as-is on the bulk result
159    /// flash / page; redact sensitive details before constructing.
160    pub reason: String,
161}
162
163impl BulkActionFailure {
164    /// Construct a failure entry with a reason string.
165    pub fn new(id: i64, reason: impl Into<String>) -> Self {
166        Self {
167            id,
168            reason: reason.into(),
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn ok_constructs_clean_result() {
179        let r = BulkActionResult::ok(7);
180        assert_eq!(r.succeeded, 7);
181        assert!(r.failed.is_empty());
182        assert!(r.message.is_none());
183        assert_eq!(r.total(), 7);
184    }
185
186    #[test]
187    fn partial_carries_failures_into_total() {
188        let r = BulkActionResult::partial(
189            5,
190            vec![
191                BulkActionFailure::new(42, "already overdue"),
192                BulkActionFailure::new(51, "row not active"),
193            ],
194        );
195        assert_eq!(r.succeeded, 5);
196        assert_eq!(r.failed.len(), 2);
197        assert_eq!(r.total(), 7);
198        assert_eq!(r.failed[0].id, 42);
199        assert_eq!(r.failed[0].reason, "already overdue");
200    }
201
202    #[test]
203    fn with_message_attaches_summary() {
204        let r = BulkActionResult::ok(3).with_message("Marked 3 loans overdue");
205        assert_eq!(r.message.as_deref(), Some("Marked 3 loans overdue"));
206    }
207
208    #[test]
209    fn default_is_zero_result() {
210        let r = BulkActionResult::default();
211        assert_eq!(r.succeeded, 0);
212        assert!(r.failed.is_empty());
213        assert_eq!(r.total(), 0);
214    }
215
216    #[test]
217    fn failure_new_owns_reason() {
218        // String, &str, and String::from all flow through Into<String>.
219        let f1 = BulkActionFailure::new(1, "static");
220        let f2 = BulkActionFailure::new(2, String::from("owned"));
221        let f3 = BulkActionFailure::new(3, format!("{}-{}", "fmt", "string"));
222        assert_eq!(f1.reason, "static");
223        assert_eq!(f2.reason, "owned");
224        assert_eq!(f3.reason, "fmt-string");
225    }
226}