use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use vti_common::pagination::{Cursor, MAX_LIMIT, Paginated};
use crate::acl::{VtcAclEntry, VtcRole, get_acl_entry, list_acl_entries};
use crate::error::{AppError, TaskError};
use crate::members::{Disposition, Member, get_member, list_members_filtered};
use crate::server::AppState;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct MemberResponse {
pub did: String,
pub role: VtcRole,
pub label: Option<String>,
pub joined_at: DateTime<Utc>,
pub publish_consent: bool,
pub departure_preference: Disposition,
pub status_list_index: Option<u32>,
pub current_vmc_id: Option<String>,
pub current_role_vac_id: Option<String>,
#[serde(serialize_with = "empty_object_if_null")]
pub extensions: JsonValue,
pub personhood: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub personhood_asserted_at: Option<DateTime<Utc>>,
#[serde(default)]
pub joined_via_invitation: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub member_vmc_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub member_vmc_received_at: Option<DateTime<Utc>>,
}
fn empty_object_if_null<S: serde::Serializer>(v: &JsonValue, s: S) -> Result<S::Ok, S::Error> {
use serde::Serialize;
match v {
JsonValue::Null => serde_json::Map::new().serialize(s),
other => other.serialize(s),
}
}
impl MemberResponse {
fn from_pair(acl: VtcAclEntry, member: Member) -> Self {
debug_assert_eq!(
acl.did, member.did,
"ACL + Member rows must share their DID — caller is responsible for the join"
);
Self {
did: member.did,
role: acl.role,
label: acl.label,
joined_at: member.joined_at,
publish_consent: member.publish_consent,
departure_preference: member.departure_preference,
status_list_index: member.status_list_index,
current_vmc_id: member.current_vmc_id,
current_role_vac_id: member.current_role_vac_id,
extensions: member.extensions,
personhood: member.personhood,
personhood_asserted_at: member.personhood_asserted_at,
joined_via_invitation: member.joined_via_invitation,
member_vmc_id: member.member_vmc_id,
member_vmc_received_at: member.member_vmc_received_at,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema, utoipa::IntoParams)]
pub struct ListMembersQuery {
pub role: Option<String>,
pub cursor: Option<String>,
pub limit: Option<usize>,
}
pub(crate) async fn list_members_inner(
state: &AppState,
query: ListMembersQuery,
) -> Result<Paginated<MemberResponse>, AppError> {
let limit = query.limit.unwrap_or(50).clamp(1, MAX_LIMIT);
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let audit_key = audit_writer.active_key().await?;
let decoded_cursor = match &query.cursor {
Some(s) => Some(Cursor::decode(s, &audit_key.key)?),
None => None,
};
let acl: std::collections::HashMap<String, VtcAclEntry> = list_acl_entries(&state.acl_ks)
.await?
.into_iter()
.map(|e| (e.did.clone(), e))
.collect();
let listed = |member: &Member| match acl.get(&member.did) {
Some(entry) => query
.role
.as_ref()
.is_none_or(|filter| entry.role.to_string() == *filter),
None if member.removed_at.is_some() => {
tracing::debug!(
did = %member.did,
"skipping tombstoned/historical member (no ACL) in list response"
);
false
}
None => {
tracing::warn!(
did = %member.did,
"member row has no matching ACL entry; skipping in list response"
);
false
}
};
let page = list_members_filtered(
&state.members_ks,
&audit_key,
decoded_cursor.as_ref(),
limit,
listed,
)
.await?;
let Paginated {
items: members,
next_cursor,
total_estimate,
} = page;
let items = members
.into_iter()
.filter_map(|m| {
acl.get(&m.did)
.cloned()
.map(|entry| MemberResponse::from_pair(entry, m))
})
.collect();
Ok(Paginated {
items,
next_cursor,
total_estimate,
})
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RemovedMember {
pub did: String,
pub removed_at: DateTime<Utc>,
pub status_list_index: Option<u32>,
pub status: String,
}
pub(crate) async fn list_removed(state: &AppState) -> Result<RemovedMembersResponse, AppError> {
let mut removed: Vec<RemovedMember> = crate::members::list_members(&state.members_ks)
.await?
.into_iter()
.filter_map(|m| {
m.removed_at.map(|removed_at| RemovedMember {
did: m.did,
removed_at,
status_list_index: m.status_list_index,
status: "removed".to_string(),
})
})
.collect();
removed.sort_by_key(|b| std::cmp::Reverse(b.removed_at));
Ok(RemovedMembersResponse { removed })
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RemovedMembersResponse {
pub removed: Vec<RemovedMember>,
}
pub const SHOW_ERR_NOT_FOUND: &str =
trust_tasks_rs::specs::vtc::members::show::v0_1::error_codes::NOT_FOUND.code;
pub(crate) async fn show_member(state: &AppState, did: &str) -> Result<MemberEnvelope, TaskError> {
vti_common::identifier::validate_did("did", did)?;
let member = get_member(&state.members_ks, did).await?.ok_or_else(|| {
TaskError::declared(
SHOW_ERR_NOT_FOUND,
AppError::NotFound(format!("member not found: {did}")),
)
})?;
let acl = get_acl_entry(&state.acl_ks, did).await?.ok_or_else(|| {
TaskError::declared(
SHOW_ERR_NOT_FOUND,
AppError::NotFound(format!("member not found (no ACL row): {did}")),
)
})?;
Ok(MemberEnvelope {
member: MemberResponse::from_pair(acl, member),
})
}
#[allow(dead_code)]
pub(crate) async fn list_admin_dids(state: &AppState) -> Result<Vec<String>, AppError> {
let entries = list_acl_entries(&state.acl_ks).await?;
Ok(entries
.into_iter()
.filter(|e| e.admin.is_administrator())
.map(|e| e.did)
.collect())
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MemberEnvelope {
pub member: MemberResponse,
}