Conversation
There was a problem hiding this comment.
Pull request overview
Implements domain contact updates end-to-end: adds OpenSRS set_contact request/response support, exposes new client/types, and introduces a CLI command to update OpenSRS + local DB contacts with audit logging. Also updates DB documentation to use REAL for money fields.
Changes:
- Add OpenSRS
set_contactXML serialization/deserialization plus a client method to update contacts. - Add new OpenSRS contact update types/conversions and a CLI subcommand to perform updates and persist/audit locally.
- Update docs to represent monetary columns as
REALinstead ofDECIMAL.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/opensrs/xml.rs | Adds set_contact XML serializer/parser; updates get_domain all_info parsing to extract contact fields. |
| src/opensrs/types.rs | Introduces SetContact* request/response types and contact conversion helpers. |
| src/opensrs/mod.rs | Re-exports newly added OpenSRS contact update types. |
| src/opensrs/client.rs | Adds update_domain_contacts() that calls OpenSRS set_contact. |
| src/main.rs | Adds Contacts CLI command to update OpenSRS then update/relink contacts in the local DB and write an audit entry. |
| src/lib.rs | Re-exports new OpenSRS contact types for external callers. |
| src/db/contacts.rs | Adds UpdateStrategy and update_contact() to update-in-place vs create-new-and-relink; makes ContactInfo serializable for audits. |
| src/db/audit.rs | Adds audit helpers for logging contact updates with old/new JSON values. |
| docs/schema.sql | Updates schema docs to store money fields as REAL. |
| docs/erd.md | Updates ERD docs to reflect REAL money fields. |
| docs/database-schema.md | Updates schema docs and type-affinity notes to reflect REAL money fields. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| account_balance REAL NOT NULL DEFAULT 0.00, | ||
| credit_limit REAL NOT NULL DEFAULT 0.00, |
There was a problem hiding this comment.
Switching money fields to SQLite REAL (floating point) can introduce rounding issues (e.g., sums and comparisons may not be exact). If these columns represent currency amounts, consider documenting expected rounding behavior and/or using an integer minor-unit representation (cents) to keep calculations exact.
| account_balance REAL NOT NULL DEFAULT 0.00, | |
| credit_limit REAL NOT NULL DEFAULT 0.00, | |
| account_balance INTEGER NOT NULL DEFAULT 0, -- stored in minor currency units (e.g., cents) | |
| credit_limit INTEGER NOT NULL DEFAULT 0, -- stored in minor currency units (e.g., cents) |
| - `REAL`: For decimal/money values (floating point) | ||
| - `BOOLEAN`: Stored as INTEGER (0 = false, 1 = true) | ||
| - `DATETIME`: Stored as TEXT in ISO 8601 format |
There was a problem hiding this comment.
Documenting money as SQLite REAL (floating point) can mislead readers into assuming exact currency arithmetic; REAL will introduce rounding error in many cases. If these fields are currency, consider recommending integer minor units (cents) or clearly stating required rounding/formatting rules in the application layer.
| - `REAL`: For decimal/money values (floating point) | |
| - `BOOLEAN`: Stored as INTEGER (0 = false, 1 = true) | |
| - `DATETIME`: Stored as TEXT in ISO 8601 format | |
| - `REAL`: For non-monetary approximate decimal values (floating point, may introduce rounding error) | |
| - `BOOLEAN`: Stored as INTEGER (0 = false, 1 = true) | |
| - `DATETIME`: Stored as TEXT in ISO 8601 format | |
| - Monetary values: Prefer fixed-point representation, for example storing minor units as `INTEGER` (cents) or using `NUMERIC` with a clearly documented scale and application-level rounding/formatting. |
| <dt_assoc> | ||
| <item key="domain">"#, | ||
| ); | ||
| xml.push_str(&request.attributes.domain); | ||
| xml.push_str( |
There was a problem hiding this comment.
serialize_set_contact_request builds XML by concatenating unescaped user-supplied strings (domain and all contact fields). If any field contains XML metacharacters like '&' or '<', the request becomes invalid XML and can enable XML injection. Use a proper XML writer (e.g., quick_xml Writer/BytesText) or at minimum escape text node contents before appending.
| } else if name == "item" { | ||
| // Check if we're exiting a contact type | ||
| if let (Some(contact_type), Some(contact)) = | ||
| (current_contact_type.take(), current_contact.take()) | ||
| { |
There was a problem hiding this comment.
This commits the current contact on every end tag. Because the OpenSRS contact structure nests many elements inside the contact, the contact will be finalized after the first field item and the remaining fields will be dropped. Track nesting depth / a key stack so you only commit when the closing corresponds to the owner/admin/billing/tech container item.
| /// Serialize set_contact request to OpenSRS XML format | ||
| pub fn serialize_set_contact_request(request: &SetContactRequest) -> Result<String> { |
There was a problem hiding this comment.
New contact update serialization/deserialization and the modified contact parsing logic are not covered by tests in this module. Adding unit tests that (1) serialize a SetContactRequest containing fields with optional values and XML metacharacters and (2) deserialize a representative get_domain all_info response containing a fully-populated contact_set would help prevent regressions.
| impl From<&ContactInfo> for crate::db::contacts::ContactInfo { | ||
| fn from(contact: &ContactInfo) -> Self { | ||
| Self { | ||
| contact_type: String::new(), |
There was a problem hiding this comment.
This From<&ContactInfo> conversion produces a db::contacts::ContactInfo with contact_type set to an empty string, which is not a valid/meaningful value and can violate DB constraints if used. Prefer a conversion API that requires the contact role/type as an input (e.g., a helper fn taking a role, or a TryFrom that validates), rather than implementing From with an invalid default.
| contact_type: String::new(), | |
| // Use a nonempty default contact type to avoid invalid/meaningless values. | |
| contact_type: "unknown".to_string(), |
| let _ = DomainContacts::delete_many() | ||
| .filter(domain_contacts::Column::DomainId.eq(did)) | ||
| .filter(domain_contacts::Column::ContactRole.eq(&contact_info.contact_type)) | ||
| .exec(db) | ||
| .await; |
There was a problem hiding this comment.
update_contact ignores the result of deleting the existing (domain_id, contact_role) link. If the delete fails or deletes 0 rows, the subsequent insert can fail due to the (domain_id, contact_role) primary key, and the underlying cause will be obscured. Propagate the delete result (and consider asserting that exactly 1 row was deleted) before inserting the new link.
| let _ = DomainContacts::delete_many() | |
| .filter(domain_contacts::Column::DomainId.eq(did)) | |
| .filter(domain_contacts::Column::ContactRole.eq(&contact_info.contact_type)) | |
| .exec(db) | |
| .await; | |
| DomainContacts::delete_many() | |
| .filter(domain_contacts::Column::DomainId.eq(did)) | |
| .filter(domain_contacts::Column::ContactRole.eq(&contact_info.contact_type)) | |
| .exec(db) | |
| .await?; |
src/db/audit.rs
Outdated
| let old_json = old_values.map(|c| { | ||
| serde_json::to_string(c).unwrap_or_default() | ||
| }); | ||
| let new_json = new_values.map(|c| { | ||
| serde_json::to_string(c).unwrap_or_default() | ||
| }); |
There was a problem hiding this comment.
log_contact_update silently drops audit JSON if serde_json::to_string fails (unwrap_or_default produces an empty string). For audit logs, silently losing old values makes troubleshooting harder. Consider handling serialization failure explicitly (e.g., store an error marker string, or return an error) instead of defaulting to empty.
| let old_json = old_values.map(|c| { | |
| serde_json::to_string(c).unwrap_or_default() | |
| }); | |
| let new_json = new_values.map(|c| { | |
| serde_json::to_string(c).unwrap_or_default() | |
| }); | |
| let old_json = match old_values { | |
| Some(c) => match serde_json::to_string(c) { | |
| Ok(s) => Some(s), | |
| Err(_) => Some(r#"{"error":"failed to serialize old contact values"}"#.to_string()), | |
| }, | |
| None => None, | |
| }; | |
| let new_json = match new_values { | |
| Some(c) => match serde_json::to_string(c) { | |
| Ok(s) => Some(s), | |
| Err(_) => Some(r#"{"error":"failed to serialize new contact values"}"#.to_string()), | |
| }, | |
| None => None, | |
| }; |
Done
Meta
(Automated in
.just/gh-process.just.)