use axum::Json;
use axum::extract::State;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::auth::{AuthClaims, SuperAdminAuth};
use crate::community::{CommunityProfileUpdate, load_profile, store_profile};
use crate::config_store::ConfigStore;
use crate::error::AppError;
use crate::server::AppState;
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ConfigResponse {
pub vtc_did: Option<String>,
pub vtc_name: Option<String>,
pub vtc_description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_url: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UpdateConfigResponse {
#[serde(flatten)]
pub config: ConfigResponse,
pub pending_restart: Vec<String>,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateConfigRequest {
pub vtc_did: Option<String>,
pub vta_did: Option<String>,
pub vtc_name: Option<String>,
pub vtc_description: Option<String>,
pub public_url: Option<String>,
}
async fn resolved_name_description(
state: &AppState,
) -> Result<(Option<String>, Option<String>), AppError> {
if let Some(profile) = load_profile(&state.community_ks).await? {
Ok((Some(profile.name), Some(profile.description)))
} else {
let config = state.config.read().await;
Ok((config.vtc_name.clone(), config.vtc_description.clone()))
}
}
async fn resolved_public_url(state: &AppState) -> Result<Option<String>, AppError> {
if let Ok(v) = std::env::var("VTC_PUBLIC_URL") {
return Ok(Some(v));
}
let store = ConfigStore::new(state.config_ks.clone());
if let Some(v) = store.get("public_url").await? {
return Ok(v.as_str().map(str::to_string));
}
Ok(state.config.read().await.public_url.clone())
}
#[utoipa::path(
get, path = "/config", tag = "config",
security(("bearer_jwt" = [])),
responses(
(status = 200, description = "Resolved config view", body = ConfigResponse),
(status = 401, description = "Missing or invalid bearer token"),
),
)]
pub async fn get_config(
auth: AuthClaims,
State(state): State<AppState>,
) -> Result<Json<ConfigResponse>, AppError> {
let (vtc_name, vtc_description) = resolved_name_description(&state).await?;
let public_url = resolved_public_url(&state).await?;
let vtc_did = state.config.read().await.vtc_did.clone();
info!(caller = %auth.did, "config retrieved");
Ok(Json(ConfigResponse {
vtc_did,
vtc_name,
vtc_description,
public_url,
}))
}
#[utoipa::path(
patch, path = "/config", tag = "config",
security(("bearer_jwt" = [])),
request_body = UpdateConfigRequest,
responses(
(status = 200, description = "Updated config view", body = UpdateConfigResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not a super-admin"),
(status = 409, description = "Attempt to rewrite immutable identity (vtc_did / vta_did)"),
),
)]
pub async fn update_config(
auth: SuperAdminAuth,
State(state): State<AppState>,
Json(req): Json<UpdateConfigRequest>,
) -> Result<Json<UpdateConfigResponse>, AppError> {
if req.vtc_did.is_some() || req.vta_did.is_some() {
return Err(AppError::Conflict(
"vtc_did / vta_did are set at `vtc setup` and cannot be changed at runtime; \
refusing to rewrite community identity"
.into(),
));
}
let mut pending_restart = Vec::new();
if req.vtc_name.is_some() || req.vtc_description.is_some() {
let mut profile = load_profile(&state.community_ks).await?.ok_or_else(|| {
AppError::Conflict(
"community profile not initialised — set name/description at setup or via \
`PUT /v1/community/profile` first"
.into(),
)
})?;
let patch = CommunityProfileUpdate {
name: req.vtc_name.clone(),
description: req.vtc_description.clone(),
..CommunityProfileUpdate::default()
};
let changed = patch.apply(&mut profile)?;
if !changed.is_empty() {
store_profile(&state.community_ks, &profile).await?;
}
}
if let Some(public_url) = req.public_url.clone() {
let store = ConfigStore::new(state.config_ks.clone());
store
.put("public_url", &serde_json::Value::String(public_url))
.await?;
pending_restart.push("public_url".into());
}
let (vtc_name, vtc_description) = resolved_name_description(&state).await?;
let public_url = resolved_public_url(&state).await?;
let vtc_did = state.config.read().await.vtc_did.clone();
info!(caller = %auth.0.did, ?pending_restart, "config updated");
Ok(Json(UpdateConfigResponse {
config: ConfigResponse {
vtc_did,
vtc_name,
vtc_description,
public_url,
},
pending_restart,
}))
}