diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index a4964a52..eade9974 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -66,46 +66,46 @@ pub struct VMPatchRequest { #[derive(Serialize, Deserialize)] pub struct AccountPatchRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] + pub email: Option>, pub contact_nip17: bool, pub contact_email: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub country_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub address_1: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub address_2: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub state: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub city: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub postcode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tax_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] + pub country_code: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] + pub name: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] + pub address_1: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] + pub address_2: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] + pub state: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] + pub city: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] + pub postcode: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] + pub tax_id: Option>, /// Nostr Wallet Connect connection string for automatic VM renewals - #[serde(skip_serializing_if = "Option::is_none")] - pub nwc_connection_string: Option, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] + pub nwc_connection_string: Option>, } impl From for AccountPatchRequest { fn from(user: lnvps_db::User) -> Self { AccountPatchRequest { - email: user.email.map(|e| e.into()), + email: Some(user.email.map(|e| e.into())), contact_nip17: user.contact_nip17, contact_email: user.contact_email, - country_code: user.country_code, - name: user.billing_name, - address_1: user.billing_address_1, - address_2: user.billing_address_2, - state: user.billing_state, - city: user.billing_city, - postcode: user.billing_postcode, - tax_id: user.billing_tax_id, - nwc_connection_string: user.nwc_connection_string.map(|nwc| nwc.into()), + country_code: Some(user.country_code), + name: Some(user.billing_name), + address_1: Some(user.billing_address_1), + address_2: Some(user.billing_address_2), + state: Some(user.billing_state), + city: Some(user.billing_city), + postcode: Some(user.billing_postcode), + tax_id: Some(user.billing_tax_id), + nwc_connection_string: Some(user.nwc_connection_string.map(|nwc| nwc.into())), } } } diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 723146e4..cdec7373 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -101,7 +101,7 @@ async fn v1_patch_account( // validate nwc string #[cfg(feature = "nostr-nwc")] - if let Some(nwc) = &req.nwc_connection_string { + if let Some(Some(nwc)) = &req.nwc_connection_string { match nwc::prelude::NostrWalletConnectURI::parse(nwc) { Ok(s) => { // test connection @@ -115,22 +115,42 @@ async fn v1_patch_account( } } - user.email = req.email.clone().map(|s| s.into()); + // Update fields only if they are present in the request + if let Some(email) = &req.email { + user.email = email.clone().map(|s| s.into()); + } user.contact_nip17 = req.contact_nip17; user.contact_email = req.contact_email; - user.country_code = req - .country_code - .as_ref() - .and_then(|c| CountryCode::for_alpha3(c).ok()) - .map(|c| c.alpha3().to_string()); - user.billing_name = req.name.clone(); - user.billing_address_1 = req.address_1.clone(); - user.billing_address_2 = req.address_2.clone(); - user.billing_city = req.city.clone(); - user.billing_state = req.state.clone(); - user.billing_postcode = req.postcode.clone(); - user.billing_tax_id = req.tax_id.clone(); - user.nwc_connection_string = req.nwc_connection_string.clone().map(|s| s.into()); + if let Some(country_code) = &req.country_code { + user.country_code = country_code + .as_ref() + .and_then(|c| CountryCode::for_alpha3(c).ok()) + .map(|c| c.alpha3().to_string()); + } + if let Some(name) = &req.name { + user.billing_name = name.clone(); + } + if let Some(address_1) = &req.address_1 { + user.billing_address_1 = address_1.clone(); + } + if let Some(address_2) = &req.address_2 { + user.billing_address_2 = address_2.clone(); + } + if let Some(city) = &req.city { + user.billing_city = city.clone(); + } + if let Some(state) = &req.state { + user.billing_state = state.clone(); + } + if let Some(postcode) = &req.postcode { + user.billing_postcode = postcode.clone(); + } + if let Some(tax_id) = &req.tax_id { + user.billing_tax_id = tax_id.clone(); + } + if let Some(nwc_connection_string) = &req.nwc_connection_string { + user.nwc_connection_string = nwc_connection_string.clone().map(|s| s.into()); + } this.db.update_user(&user).await?; ApiData::ok(()) diff --git a/lnvps_api_common/src/lib.rs b/lnvps_api_common/src/lib.rs index 390e403c..44239f16 100644 --- a/lnvps_api_common/src/lib.rs +++ b/lnvps_api_common/src/lib.rs @@ -91,6 +91,21 @@ where } } +/// Custom deserializer that distinguishes between missing field and explicit null +/// Used for PATCH endpoints to allow clearing optional fields +/// +/// Returns: +/// - `None` when field is not present in JSON (due to `#[serde(default)]`) +/// - `Some(None)` when field is present with `null` value +/// - `Some(Some(value))` when field is present with a value +pub fn deserialize_nullable_option<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + Ok(Some(Option::deserialize(deserializer)?)) +} + // Custom deserializer to handle Proxmox's integer-to-boolean conversion for KVM field pub fn deserialize_int_to_bool<'de, D>(deserializer: D) -> anyhow::Result, D::Error>