use axum::Json;
use axum::extract::{Path, Query, State};
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::auth::AdminAuth;
use crate::error::AppError;
use crate::members::{Disposition, Member, get_member, list_members_paginated};
use crate::server::AppState;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
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_vec_id: Option<String>,
pub extensions: JsonValue,
pub personhood: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub personhood_asserted_at: Option<DateTime<Utc>>,
}
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_vec_id: member.current_role_vec_id,
extensions: member.extensions,
personhood: member.personhood,
personhood_asserted_at: member.personhood_asserted_at,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListMembersQuery {
pub role: Option<String>,
pub cursor: Option<String>,
pub limit: Option<usize>,
}
pub async fn list_members(
_auth: AdminAuth,
State(state): State<AppState>,
Query(query): Query<ListMembersQuery>,
) -> Result<Json<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 mut page = list_members_paginated(
&state.members_ks,
&audit_key,
decoded_cursor.as_ref(),
limit,
)
.await?;
let mut items = Vec::with_capacity(page.items.len());
for member in page.items.drain(..) {
match get_acl_entry(&state.acl_ks, &member.did).await? {
Some(acl) => {
if let Some(filter) = &query.role
&& acl.role.to_string() != *filter
{
continue;
}
items.push(MemberResponse::from_pair(acl, member));
}
None => {
tracing::warn!(
did = %member.did,
"member row has no matching ACL entry; skipping in list response"
);
}
}
}
Ok(Json(Paginated {
items,
next_cursor: page.next_cursor,
total_estimate: page.total_estimate,
}))
}
pub async fn show_member(
_auth: AdminAuth,
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<MemberResponse>, AppError> {
let member = get_member(&state.members_ks, &did)
.await?
.ok_or_else(|| AppError::NotFound(format!("member not found: {did}")))?;
let acl = get_acl_entry(&state.acl_ks, &did).await?.ok_or_else(|| {
AppError::NotFound(format!("member not found (no ACL row): {did}"))
})?;
Ok(Json(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| matches!(e.role, VtcRole::Admin))
.map(|e| e.did)
.collect())
}