From 07abe6e9820ff217f0ff1af484b1a32125972790 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:29:57 +0000 Subject: [PATCH 1/5] Initial plan From fead424b6cd8aec0ce3d24c80adc1cde9957dc35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:35:35 +0000 Subject: [PATCH 2/5] Fix account field clearing in PATCH endpoint Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_api/src/api/model.rs | 72 +++++++++++++++++++++---------------- lnvps_api/src/api/routes.rs | 50 ++++++++++++++++++-------- 2 files changed, 76 insertions(+), 46 deletions(-) diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index a4964a52..7e53af8d 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -9,12 +9,22 @@ use lnvps_api_common::{ApiDiskInterface, ApiDiskType}; use lnvps_db::{PaymentMethod, PaymentType, VmCustomTemplate}; use payments_rs::currency::{Currency, CurrencyAmount}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Deserializer}; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; +// Custom deserializer that distinguishes between missing field and explicit null +// Used for PATCH endpoints to allow clearing optional fields +fn deserialize_nullable_option<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Ok(Some(Option::deserialize(deserializer)?)) +} + #[derive(Serialize, Deserialize)] pub struct ApiCustomVmOrder { #[serde(flatten)] @@ -66,46 +76,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 = "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 = "deserialize_nullable_option")] + pub country_code: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_nullable_option")] + pub name: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_nullable_option")] + pub address_1: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_nullable_option")] + pub address_2: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_nullable_option")] + pub state: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_nullable_option")] + pub city: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_nullable_option")] + pub postcode: Option>, + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "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 = "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(()) From 99fdf857373e255e918fa1ec68435f38f29c455c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:44:48 +0000 Subject: [PATCH 3/5] Move deserialize_nullable_option to lnvps_api_common Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_api/src/api/model.rs | 12 +----------- lnvps_api_common/src/lib.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 7e53af8d..6bbe69b2 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -9,22 +9,12 @@ use lnvps_api_common::{ApiDiskInterface, ApiDiskType}; use lnvps_db::{PaymentMethod, PaymentType, VmCustomTemplate}; use payments_rs::currency::{Currency, CurrencyAmount}; -use serde::{Deserialize, Serialize, Deserializer}; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -// Custom deserializer that distinguishes between missing field and explicit null -// Used for PATCH endpoints to allow clearing optional fields -fn deserialize_nullable_option<'de, D, T>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, - T: Deserialize<'de>, -{ - Ok(Some(Option::deserialize(deserializer)?)) -} - #[derive(Serialize, Deserialize)] pub struct ApiCustomVmOrder { #[serde(flatten)] 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> From b61858231089073bd96d2d1021c0b72d25881530 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:19:52 +0000 Subject: [PATCH 4/5] Fix deserialize_with to use lnvps_api_common crate path Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_api/src/api/model.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 6bbe69b2..eade9974 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -66,28 +66,28 @@ pub struct VMPatchRequest { #[derive(Serialize, Deserialize)] pub struct AccountPatchRequest { - #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_nullable_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(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_nullable_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 = "deserialize_nullable_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 = "deserialize_nullable_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 = "deserialize_nullable_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 = "deserialize_nullable_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 = "deserialize_nullable_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 = "deserialize_nullable_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 = "deserialize_nullable_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(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_nullable_option")] + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option")] pub nwc_connection_string: Option>, } From b2e0cd975f7830e380c71f232b3ce956e7288b7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:29:57 +0000 Subject: [PATCH 5/5] Initial plan