use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::{info, warn};
use uuid::Uuid;
use vta_sdk::protocols::join_requests::ADMIN_REJECT_CODE;
use vti_common::audit::{AuditEvent, JoinRequestData, JoinRequestRejectedData};
use vti_common::error::AppError;
use crate::acl::VtcRole;
use crate::ceremony::execute;
use crate::ceremony::{EffectOutcome, EffectPlan};
use crate::error::TaskError;
use crate::join::{JoinDecision, JoinRequest, JoinStatus, get_join_request, store_join_request};
use crate::server::AppState;
const REJECT_REASON_MAX: usize = 1024;
use trust_tasks_rs::specs::vtc::join_requests::decide::v0_1::error_codes as decide_codes;
pub const DECIDE_ERR_NOT_FOUND: &str = decide_codes::NOT_FOUND.code;
pub const DECIDE_ERR_NOT_PENDING: &str = decide_codes::NOT_PENDING.code;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub enum Decision {
Approved,
Rejected,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct DecideBody {
pub decision: Decision,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct DecideResponse {
pub request_id: Uuid,
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vmc: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_vac: Option<JsonValue>,
}
pub(crate) async fn decide_inner(
state: &AppState,
actor_did: &str,
transport: &str,
id: Uuid,
body: DecideBody,
) -> Result<DecideResponse, TaskError> {
let reason = body.reason.unwrap_or_default();
if reason.len() > REJECT_REASON_MAX {
return Err(AppError::Validation(format!(
"decision reason exceeds {REJECT_REASON_MAX} chars (got {})",
reason.len(),
))
.into());
}
let req = get_join_request(&state.join_requests_ks, id)
.await?
.ok_or_else(|| {
TaskError::declared(
DECIDE_ERR_NOT_FOUND,
AppError::NotFound(format!("join request not found: {id}")),
)
})?;
if req.status != JoinStatus::Pending {
return Err(TaskError::declared(
DECIDE_ERR_NOT_PENDING,
AppError::Conflict(format!(
"join request {id} is {:?}, not Pending",
req.status
)),
));
}
let response = match body.decision {
Decision::Approved => approve_pending(state, actor_did, transport, id, req).await?,
Decision::Rejected => reject_pending(state, actor_did, id, req, reason).await?,
};
crate::admin_actions::queues::join_decided(
state,
id,
body.decision == Decision::Approved,
actor_did,
)
.await;
Ok(response)
}
async fn approve_pending(
state: &AppState,
actor_did: &str,
transport: &str,
id: Uuid,
mut req: JoinRequest,
) -> Result<DecideResponse, AppError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let plan = EffectPlan::Admit {
subject: req.applicant_did.clone(),
role: VtcRole::Member.to_string(),
obligations: vec![],
publish_consent: req.registry_consent,
label: None,
};
let EffectOutcome::Admitted(creds) = execute::apply(state, plan, actor_did).await? else {
return Err(AppError::Internal(
"admit effect did not produce credentials".into(),
));
};
if let Err(e) = crate::credentials::delivery::deliver_membership_credentials(
state,
&req.applicant_did,
&creds,
)
.await
{
warn!(
request_id = %id,
applicant = %req.applicant_did,
error = %e,
"membership-credential delivery failed on approve; credentials issued and returned inline"
);
}
req.status = JoinStatus::Approved;
store_join_request(&state.join_requests_ks, &req).await?;
audit_writer
.write(
actor_did,
Some(&req.applicant_did),
AuditEvent::JoinRequestApproved(JoinRequestData {
request_id: id.to_string(),
transport: transport.to_string(),
}),
)
.await?;
crate::join::emit_admit_audit(
audit_writer,
actor_did,
&req.applicant_did,
&creds,
&VtcRole::Member.to_string(),
Some(id.to_string()),
)
.await?;
info!(
request_id = %id,
applicant = %req.applicant_did,
admin = %actor_did,
status_list_index = creds.status_list_index,
"join request approved"
);
Ok(DecideResponse {
request_id: id,
status: req.status.to_string(),
vmc: Some(
serde_json::to_value(&creds.vmc)
.map_err(|e| AppError::Internal(format!("serialise VMC for response: {e}")))?,
),
role_vac: Some(
serde_json::to_value(&creds.role_vac)
.map_err(|e| AppError::Internal(format!("serialise VAC for response: {e}")))?,
),
})
}
async fn reject_pending(
state: &AppState,
actor_did: &str,
id: Uuid,
mut req: JoinRequest,
reason: String,
) -> Result<DecideResponse, AppError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
req.status = JoinStatus::Rejected;
req.decision = Some(JoinDecision {
code: ADMIN_REJECT_CODE.to_string(),
reason: (!reason.is_empty()).then(|| reason.clone()),
decided_at: Utc::now(),
});
store_join_request(&state.join_requests_ks, &req).await?;
audit_writer
.write(
actor_did,
Some(&req.applicant_did),
AuditEvent::JoinRequestRejected(JoinRequestRejectedData {
request_id: id.to_string(),
reason: reason.clone(),
policy_decision: None,
}),
)
.await?;
info!(
request_id = %id,
applicant = %req.applicant_did,
admin = %actor_did,
reason_present = !reason.is_empty(),
"join request rejected"
);
Ok(DecideResponse {
request_id: id,
status: req.status.to_string(),
vmc: None,
role_vac: None,
})
}