use axum::Extension;
use axum::Json;
use axum::extract::RawQuery;
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use sqlx::{QueryBuilder, Sqlite};
use super::common::{AdminError, AdminState, verify_and_authorize};
use super::pending_action_view::{
PendingActionEntry, PendingActionRow, project, subject_has_pending_actions,
};
use crate::server::xrpc::{decode_cursor, encode_cursor};
const LXM: &str = "tools.cairn.admin.listPendingActions";
const DEFAULT_LIMIT: i64 = 50;
const MIN_LIMIT: i64 = 1;
const MAX_LIMIT: i64 = 250;
const DEFAULT_RESOLUTION: &str = "pending";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResolutionFilter {
Pending,
Confirmed,
Dismissed,
}
impl ResolutionFilter {
fn parse(s: &str) -> Option<Self> {
match s {
"pending" => Some(Self::Pending),
"confirmed" => Some(Self::Confirmed),
"dismissed" => Some(Self::Dismissed),
_ => None,
}
}
}
#[derive(Debug)]
struct Params {
subject: Option<String>,
resolution: ResolutionFilter,
limit: i64,
cursor: Option<String>,
}
#[derive(Debug, Serialize)]
struct Output {
actions: Vec<PendingActionEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
}
pub(super) async fn handler(
Extension(state): Extension<AdminState>,
headers: HeaderMap,
RawQuery(query): RawQuery,
) -> Response {
if let Err(e) = verify_and_authorize(&state, &headers, LXM).await {
return e.into_response();
}
let params = match parse_params(query.as_deref().unwrap_or("")) {
Ok(p) => p,
Err(e) => return e.into_response(),
};
let cursor_id = match ¶ms.cursor {
None => i64::MAX,
Some(c) => match decode_cursor(c) {
Ok(n) => n,
Err(_) => return AdminError::InvalidRequest("malformed cursor").into_response(),
},
};
if let Some(subject) = ¶ms.subject {
match subject_has_pending_actions(&state.pool, subject).await {
Ok(true) => {}
Ok(false) => return AdminError::SubjectNotFound.into_response(),
Err(_) => return AdminError::Internal.into_response(),
}
}
let mut qb = QueryBuilder::<Sqlite>::new(
"SELECT id, subject_did, subject_uri, action_type, duration_ms,
reason_codes, triggered_by_policy_rule, triggered_at,
triggering_action_id, resolution, resolved_at,
resolved_by_did, confirmed_action_id
FROM pending_policy_actions
WHERE id < ",
);
qb.push_bind(cursor_id);
if let Some(subject) = ¶ms.subject {
qb.push(" AND subject_did = ");
qb.push_bind(subject.clone());
}
match params.resolution {
ResolutionFilter::Pending => {
qb.push(" AND resolution IS NULL");
}
ResolutionFilter::Confirmed => {
qb.push(" AND resolution = 'confirmed'");
}
ResolutionFilter::Dismissed => {
qb.push(" AND resolution = 'dismissed'");
}
}
qb.push(" ORDER BY id DESC LIMIT ");
qb.push_bind(params.limit + 1);
let rows: Vec<PendingActionRow> = match qb
.build_query_as::<PendingActionRow>()
.fetch_all(&state.pool)
.await
{
Ok(r) => r,
Err(_) => return AdminError::Internal.into_response(),
};
let (returned, next_cursor) = if rows.len() as i64 > params.limit {
let mut trimmed = rows;
trimmed.truncate(params.limit as usize);
let last_id = trimmed.last().expect("trimmed non-empty").id;
(trimmed, Some(encode_cursor(last_id)))
} else {
(rows, None)
};
let mut actions = Vec::with_capacity(returned.len());
for row in returned {
match project(row) {
Ok(e) => actions.push(e),
Err(_) => return AdminError::Internal.into_response(),
}
}
Json(Output {
actions,
cursor: next_cursor,
})
.into_response()
}
fn parse_params(raw: &str) -> Result<Params, AdminError> {
let mut subject: Option<String> = None;
let mut resolution_str: Option<String> = None;
let mut limit: i64 = DEFAULT_LIMIT;
let mut cursor: Option<String> = None;
for (k, v) in form_urlencoded::parse(raw.as_bytes()) {
match k.as_ref() {
"subject" if !v.is_empty() => subject = Some(v.into_owned()),
"resolution" if !v.is_empty() => resolution_str = Some(v.into_owned()),
"limit" => {
let n = v
.parse::<i64>()
.map_err(|_| AdminError::InvalidRequest("limit must be integer"))?;
if !(MIN_LIMIT..=MAX_LIMIT).contains(&n) {
return Err(AdminError::InvalidRequest("limit out of range"));
}
limit = n;
}
"cursor" if !v.is_empty() => cursor = Some(v.into_owned()),
_ => {}
}
}
if let Some(ref s) = subject
&& !s.starts_with("did:")
{
return Err(AdminError::InvalidRequest("subject must be a DID"));
}
let resolution_owned = resolution_str.unwrap_or_else(|| DEFAULT_RESOLUTION.to_string());
let resolution = ResolutionFilter::parse(&resolution_owned).ok_or(
AdminError::InvalidRequest("resolution must be pending, confirmed, or dismissed"),
)?;
Ok(Params {
subject,
resolution,
limit,
cursor,
})
}