use topcoat::{
context::Cx,
router::{
Body,
error::{bad_request, forbidden, not_found, see_other},
},
view::BoxView,
};
use super::{
super::{
forms::{parse_form_body, truthy},
gate::{gate, landing_url},
write::commit_write,
},
fetch::{composite_pk_error, find_by_key},
};
use crate::{
db::db,
notification::{Notification, set_notification},
policy::Ability,
resource::{ActionEntry, Mounted, Resource},
topcoat_compat::async_page,
};
#[derive(Clone, Copy)]
enum Target {
Row,
Bulk,
}
pub(crate) fn resource_delete<R: Resource>(cx: &Cx, body: Body) -> BoxView<'_> {
run_mutation::<R>(cx, body, Target::Row, |_| Some(ActionEntry::delete()))
}
pub(crate) fn resource_bulk_delete<R: Resource>(cx: &Cx, body: Body) -> BoxView<'_> {
run_mutation::<R>(cx, body, Target::Bulk, |_| Some(ActionEntry::bulk_delete()))
}
pub(crate) fn resource_row_action<R: Resource>(cx: &Cx, body: Body) -> BoxView<'_> {
run_mutation::<R>(cx, body, Target::Row, |resource| {
custom_action(cx, resource, |action| action.row)
})
}
pub(crate) fn resource_bulk_action<R: Resource>(cx: &Cx, body: Body) -> BoxView<'_> {
run_mutation::<R>(cx, body, Target::Bulk, |resource| {
custom_action(cx, resource, |action| action.bulk)
})
}
fn custom_action<R: Resource>(
cx: &Cx,
resource: &Mounted<R>,
offered: fn(&ActionEntry<R>) -> bool,
) -> Option<ActionEntry<R>> {
let name = topcoat::router::path_param_segment(cx, "action");
resource.actions.find(name).filter(|a| offered(a)).copied()
}
fn run_mutation<'a, R: Resource>(
cx: &'a Cx,
body: Body,
target: Target,
select: impl FnOnce(&Mounted<R>) -> Option<ActionEntry<R>> + Send + 'a,
) -> BoxView<'a> {
async_page(async move {
let resource = gate::<R>(cx)?;
let action = select(&resource);
let resource_wide = action.map_or(Ability::ViewAny, |action| action.resource_wide);
if !resource.can(cx, resource_wide) {
return Err(forbidden().into());
}
let Some(action) = action else {
return Err(not_found().into());
};
let values = parse_form_body(cx, body).await?.values;
crate::csrf::verify(cx, &values)?;
if action.confirm && !values.get("confirm").is_some_and(|v| truthy(v)) {
return Err(bad_request(format!("{} requires confirmation", action.name)).into());
}
let ids = match target {
Target::Row => vec![topcoat::router::path_param_segment(cx, "id").to_string()],
Target::Bulk => {
let raw = values.get("ids").cloned().unwrap_or_default();
let ids = parse_bulk_ids(&raw, MAX_BULK_IDS);
if ids.is_empty() {
set_notification(cx, Notification::error("Select at least one row first"));
return Err(see_other(landing_url(cx, &resource.url)).into());
}
if ids.len() > MAX_BULK_IDS {
return Err(bad_request(format!("too many ids (max {MAX_BULK_IDS})")).into());
}
ids
}
};
let mut db = db(cx);
let mut tx = db.transaction().await.map_err(crate::error::unavailable)?;
let rows = load_targets(cx, &resource, &ids, target, &mut tx).await?;
if rows.iter().any(|row| !resource.can(cx, Ability::View(row))) {
return Err(forbidden().into());
}
let (rows, refused): (Vec<R::Model>, Vec<R::Model>) = rows
.into_iter()
.partition(|row| (action.can_run)(&resource, cx, row));
if rows.is_empty() {
return Err(refuse(cx, &resource, &action, target, refused.len()));
}
let mut note = (action.success)(cx, rows.len());
if !refused.is_empty() {
let skipped = refused.len();
let selected = rows.len() + skipped;
note.push_str(&format!(" ({skipped} of {selected} skipped)"));
}
let written = (action.run)(cx, &rows, &mut tx).await.map(|()| rows);
commit_write(
cx,
&resource,
tx,
written,
action.acted,
note,
action.failure,
)
.await
})
}
async fn load_targets<R: Resource>(
cx: &Cx,
resource: &Mounted<R>,
ids: &[String],
target: Target,
tx: &mut toasty::Transaction<'_>,
) -> Result<Vec<R::Model>, topcoat::Error> {
if let Target::Row = target {
return Ok(vec![find_by_key(cx, resource, &ids[0], tx).await?]);
}
let keys: Vec<&str> = ids.iter().map(String::as_str).collect();
let Some(pk_filter) = crate::toasty_compat::pk::pk_in_expr::<R::Model>(&keys) else {
if let Some(error) = composite_pk_error(resource) {
return Err(error);
}
return Err(not_found().into());
};
let rows = resource
.scoped_query(cx)?
.filter(pk_filter)
.exec(tx)
.await
.map_err(crate::error::unavailable)?;
if rows.len() != ids.len() {
return Err(not_found().into());
}
Ok(rows)
}
fn refuse<R: Resource>(
cx: &Cx,
resource: &Mounted<R>,
action: &ActionEntry<R>,
target: Target,
refused: usize,
) -> topcoat::Error {
match target {
Target::Row => forbidden().into(),
Target::Bulk => {
let noun = if refused == 1 { "record" } else { "records" };
set_notification(
cx,
Notification::error(format!(
"{}: {refused} selected {noun} cannot take this action; nothing was changed",
(action.label)(cx)
)),
);
see_other(landing_url(cx, &resource.url)).into()
}
}
}
pub(super) const MAX_BULK_IDS: usize = 400;
pub(super) fn parse_bulk_ids(raw: &str, max: usize) -> Vec<String> {
let mut ids: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for s in raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
if seen.insert(s) {
ids.push(s.to_string());
if ids.len() > max {
break;
}
}
}
ids
}
#[cfg(test)]
mod tests;