commit c0ac109f122be699c122bdd3c2220b352aa44fba
parent 2b4fd9b5e013c96586eab39fe8db1256ea578603
Author: triesap <tyson@radroots.org>
Date: Sat, 4 Jul 2026 01:02:34 +0000
runtime: require explicit order note confirmation
- route SDK trade proposal through product-shaped request fields
- persist buyer order note confirmation through cart and order state
- block note-bearing order review until confirmation is checked
- carry confirmation into sync payloads and SDK privacy preflight
Diffstat:
13 files changed, 424 insertions(+), 48 deletions(-)
diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs
@@ -215,7 +215,10 @@ struct AppDirectRelayFetchedRelay {
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AppSellerOrderDecisionCommand {
Accept,
- Decline { reason: String },
+ Decline {
+ reason: String,
+ confirm_public_note: bool,
+ },
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -814,11 +817,13 @@ impl DesktopAppRuntime {
&self,
order_id: OrderId,
reason: &str,
+ confirm_public_note: bool,
) -> Result<AppOrderDecisionPublishPayload, AppSqliteError> {
self.lock_state_mut().prepare_seller_order_decision(
order_id,
AppSellerOrderDecisionCommand::Decline {
reason: reason.to_owned(),
+ confirm_public_note,
},
)
}
@@ -832,11 +837,13 @@ impl DesktopAppRuntime {
&self,
order_id: OrderId,
reason: &str,
+ confirm_public_note: bool,
) -> Result<bool, AppSqliteError> {
self.lock_state_mut().publish_seller_order_decision(
order_id,
AppSellerOrderDecisionCommand::Decline {
reason: reason.to_owned(),
+ confirm_public_note,
},
)
}
@@ -847,14 +854,25 @@ impl DesktopAppRuntime {
items: Vec<RadrootsOrderItem>,
economics: RadrootsOrderEconomics,
reason: &str,
+ confirm_public_note: bool,
) -> Result<bool, AppSqliteError> {
self.lock_state_mut()
- .publish_seller_order_revision_proposal(order_id, items, economics, reason)
+ .publish_seller_order_revision_proposal(
+ order_id,
+ items,
+ economics,
+ reason,
+ confirm_public_note,
+ )
}
- pub fn publish_buyer_order_cancel(&self, order_id: OrderId) -> Result<bool, AppSqliteError> {
+ pub fn publish_buyer_order_cancel(
+ &self,
+ order_id: OrderId,
+ confirm_public_note: bool,
+ ) -> Result<bool, AppSqliteError> {
self.lock_state_mut()
- .publish_buyer_order_cancellation(order_id)
+ .publish_buyer_order_cancellation(order_id, confirm_public_note)
}
pub fn publish_buyer_order_revision_accept(
@@ -868,9 +886,10 @@ impl DesktopAppRuntime {
pub fn publish_buyer_order_revision_decline(
&self,
order_id: OrderId,
+ confirm_public_note: bool,
) -> Result<bool, AppSqliteError> {
self.lock_state_mut()
- .publish_buyer_order_revision_decline(order_id)
+ .publish_buyer_order_revision_decline(order_id, confirm_public_note)
}
pub fn open_pack_day(
@@ -2611,11 +2630,18 @@ impl DesktopAppRuntimeState {
});
}
+ let confirm_public_note = match &command {
+ AppSellerOrderDecisionCommand::Accept => false,
+ AppSellerOrderDecisionCommand::Decline {
+ confirm_public_note,
+ ..
+ } => *confirm_public_note,
+ };
let decision = match command {
AppSellerOrderDecisionCommand::Accept => AppOrderDecisionPayload::Accepted {
inventory_commitments: seller_order_inventory_commitments(&order_export)?,
},
- AppSellerOrderDecisionCommand::Decline { reason } => {
+ AppSellerOrderDecisionCommand::Decline { reason, .. } => {
let reason = reason.trim();
if reason.is_empty() {
return Err(AppSqliteError::InvalidProjection {
@@ -2638,6 +2664,7 @@ impl DesktopAppRuntimeState {
buyer_pubkey: request.payload.buyer_pubkey.to_string(),
seller_pubkey: request.payload.seller_pubkey.to_string(),
decision,
+ confirm_public_note,
};
AppPublishPayload::OrderDecision(payload.clone())
.validate()
@@ -2692,6 +2719,7 @@ impl DesktopAppRuntimeState {
items: Vec<RadrootsOrderItem>,
economics: RadrootsOrderEconomics,
reason: &str,
+ confirm_public_note: bool,
) -> Result<AppOrderRevisionProposalPublishPayload, AppSqliteError> {
let _ = self.import_shared_local_events()?;
let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| {
@@ -2792,6 +2820,7 @@ impl DesktopAppRuntimeState {
items,
economics,
reason: reason.to_owned(),
+ confirm_public_note,
};
AppPublishPayload::OrderRevisionProposal(payload.clone())
.validate()
@@ -2807,9 +2836,15 @@ impl DesktopAppRuntimeState {
items: Vec<RadrootsOrderItem>,
economics: RadrootsOrderEconomics,
reason: &str,
+ confirm_public_note: bool,
) -> Result<bool, AppSqliteError> {
- let payload =
- self.prepare_seller_order_revision_proposal(order_id, items, economics, reason)?;
+ let payload = self.prepare_seller_order_revision_proposal(
+ order_id,
+ items,
+ economics,
+ reason,
+ confirm_public_note,
+ )?;
let source_record_id = order_revision_proposal_sdk_source_record_id(&payload);
self.enqueue_order_revision_proposal_payload_via_sdk(
&payload,
@@ -2824,6 +2859,7 @@ impl DesktopAppRuntimeState {
&mut self,
order_id: OrderId,
decision: RadrootsOrderRevisionOutcome,
+ confirm_public_note: bool,
) -> Result<AppOrderRevisionDecisionPublishPayload, AppSqliteError> {
let _ = self.import_shared_local_events()?;
let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| {
@@ -2913,6 +2949,7 @@ impl DesktopAppRuntimeState {
buyer_pubkey: request.payload.buyer_pubkey.to_string(),
seller_pubkey: request.payload.seller_pubkey.to_string(),
decision,
+ confirm_public_note,
};
AppPublishPayload::OrderRevisionDecision(payload.clone())
.validate()
@@ -2926,18 +2963,24 @@ impl DesktopAppRuntimeState {
&mut self,
order_id: OrderId,
) -> Result<bool, AppSqliteError> {
- self.publish_buyer_order_revision_decision(order_id, RadrootsOrderRevisionOutcome::Accepted)
+ self.publish_buyer_order_revision_decision(
+ order_id,
+ RadrootsOrderRevisionOutcome::Accepted,
+ false,
+ )
}
fn publish_buyer_order_revision_decline(
&mut self,
order_id: OrderId,
+ confirm_public_note: bool,
) -> Result<bool, AppSqliteError> {
self.publish_buyer_order_revision_decision(
order_id,
RadrootsOrderRevisionOutcome::Declined {
reason: "buyer kept order as placed".to_owned(),
},
+ confirm_public_note,
)
}
@@ -2945,8 +2988,10 @@ impl DesktopAppRuntimeState {
&mut self,
order_id: OrderId,
decision: RadrootsOrderRevisionOutcome,
+ confirm_public_note: bool,
) -> Result<bool, AppSqliteError> {
- let payload = self.prepare_buyer_order_revision_decision(order_id, decision)?;
+ let payload =
+ self.prepare_buyer_order_revision_decision(order_id, decision, confirm_public_note)?;
let source_record_id = order_revision_decision_sdk_source_record_id(&payload);
self.enqueue_order_revision_decision_payload_via_sdk(
&payload,
@@ -2960,6 +3005,7 @@ impl DesktopAppRuntimeState {
fn prepare_buyer_order_cancellation(
&mut self,
order_id: OrderId,
+ confirm_public_note: bool,
) -> Result<AppOrderCancellationPublishPayload, AppSqliteError> {
let _ = self.import_shared_local_events()?;
let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| {
@@ -3052,6 +3098,7 @@ impl DesktopAppRuntimeState {
buyer_pubkey: request.payload.buyer_pubkey.to_string(),
seller_pubkey: request.payload.seller_pubkey.to_string(),
reason: "buyer cancelled order".to_owned(),
+ confirm_public_note,
};
AppPublishPayload::OrderCancellation(payload.clone())
.validate()
@@ -3064,8 +3111,9 @@ impl DesktopAppRuntimeState {
fn publish_buyer_order_cancellation(
&mut self,
order_id: OrderId,
+ confirm_public_note: bool,
) -> Result<bool, AppSqliteError> {
- let payload = self.prepare_buyer_order_cancellation(order_id)?;
+ let payload = self.prepare_buyer_order_cancellation(order_id, confirm_public_note)?;
let source_record_id = order_cancellation_sdk_source_record_id(&payload);
self.enqueue_order_cancellation_payload_via_sdk(
&payload,
@@ -4662,6 +4710,7 @@ impl DesktopAppRuntimeState {
currency_code: Some(currency_code),
total_minor_units: Some(total_minor_units),
note: non_empty_string(order.buyer_order_note.as_str()),
+ confirm_public_note: order.buyer_order_note_public_confirmed,
};
if AppPublishPayload::OrderRequest(payload.clone())
.validate()
@@ -4793,12 +4842,19 @@ impl DesktopAppRuntimeState {
))
.and_then(|identity| {
let actor_pubkey = identity.public_key_hex();
+ let order_parts = order_request_publish_payload_to_sdk_product_parts(payload)?;
let request = AppSdkTradeProposeRequest {
actor_account_id: payload.context.account_id.clone(),
actor_pubkey: actor_pubkey.clone(),
signer_keys: identity.into_keys(),
listing_event: order_request_sdk_listing_event_ptr(payload)?,
- order: order_request_publish_payload_to_sdk_order(payload)?,
+ order_id: order_parts.order_id,
+ listing_addr: order_parts.listing_addr,
+ seller_pubkey: order_parts.seller_pubkey,
+ items: order_parts.items,
+ economics: order_parts.economics,
+ public_note: payload.note.clone(),
+ confirm_public_note: payload.confirm_public_note,
idempotency_key: Some(sdk_idempotency_key(source_record_id)),
};
self.enqueue_app_sdk_trade_propose(request)
@@ -4842,6 +4898,7 @@ impl DesktopAppRuntimeState {
signer_keys: identity.into_keys(),
locator: trade_locator_from_decision_payload(payload)?,
decision: trade_decision_from_publish_payload(payload)?,
+ confirm_public_note: payload.confirm_public_note,
idempotency_key: Some(sdk_idempotency_key(source_record_id)),
};
self.enqueue_app_sdk_trade_decision(request)
@@ -4888,6 +4945,7 @@ impl DesktopAppRuntimeState {
items: payload.items.clone(),
economics: payload.economics.clone(),
reason: payload.reason.clone(),
+ confirm_public_note: payload.confirm_public_note,
idempotency_key: Some(sdk_idempotency_key(source_record_id)),
};
self.enqueue_app_sdk_trade_revision_proposal(request)
@@ -4932,6 +4990,7 @@ impl DesktopAppRuntimeState {
locator: trade_locator_from_revision_decision_payload(payload)?,
revision_id: publish_revision_id(payload.revision_id.as_str())?,
decision: payload.decision.clone(),
+ confirm_public_note: payload.confirm_public_note,
idempotency_key: Some(sdk_idempotency_key(source_record_id)),
};
self.enqueue_app_sdk_trade_revision_decision(request)
@@ -4975,6 +5034,7 @@ impl DesktopAppRuntimeState {
signer_keys: identity.into_keys(),
locator: trade_locator_from_cancellation_payload(payload)?,
reason: payload.reason.clone(),
+ confirm_public_note: payload.confirm_public_note,
idempotency_key: Some(sdk_idempotency_key(source_record_id)),
};
self.enqueue_app_sdk_trade_cancellation(request)
@@ -7735,20 +7795,47 @@ fn missing_listing_provenance_relay_error(known_relays: &[String]) -> AppSyncTra
)
}
-fn order_request_publish_payload_to_sdk_order(
+struct SdkOrderRequestProductParts {
+ order_id: RadrootsOrderId,
+ listing_addr: RadrootsListingAddress,
+ seller_pubkey: RadrootsPublicKey,
+ items: Vec<RadrootsOrderItem>,
+ economics: RadrootsOrderEconomics,
+}
+
+fn order_request_publish_payload_to_sdk_product_parts(
payload: &AppOrderRequestPublishPayload,
-) -> Result<RadrootsOrderRequest, AppSyncTransportError> {
+) -> Result<SdkOrderRequestProductParts, AppSyncTransportError> {
let Some(document_json) = payload.order_document_json.as_ref() else {
return Err(AppSyncTransportError::failed(
"order request publish requires order document",
));
};
- let order_json = document_json
- .pointer("/document/order")
- .or_else(|| document_json.get("order"))
- .unwrap_or(document_json);
- serde_json::from_value::<RadrootsOrderRequest>(order_json.clone())
- .map_err(|error| AppSyncTransportError::failed(error.to_string()))
+ let order_json = document_json.pointer("/document/order").ok_or_else(|| {
+ AppSyncTransportError::failed("order request publish document is missing order")
+ })?;
+ let items_json = order_json.get("items").ok_or_else(|| {
+ AppSyncTransportError::failed("order request publish document is missing order items")
+ })?;
+ let economics_json = order_json.get("economics").ok_or_else(|| {
+ AppSyncTransportError::failed("order request publish document is missing order economics")
+ })?;
+ let listing_addr = payload.listing_addr.as_deref().ok_or_else(|| {
+ AppSyncTransportError::failed("order request publish requires listing address")
+ })?;
+ let seller_pubkey = payload.seller_pubkey.as_deref().ok_or_else(|| {
+ AppSyncTransportError::failed("order request publish requires seller pubkey")
+ })?;
+
+ Ok(SdkOrderRequestProductParts {
+ order_id: publish_order_id(payload.order_id.to_string().as_str())?,
+ listing_addr: publish_listing_addr(listing_addr)?,
+ seller_pubkey: publish_pubkey(seller_pubkey)?,
+ items: serde_json::from_value::<Vec<RadrootsOrderItem>>(items_json.clone())
+ .map_err(|error| AppSyncTransportError::failed(error.to_string()))?,
+ economics: serde_json::from_value::<RadrootsOrderEconomics>(economics_json.clone())
+ .map_err(|error| AppSyncTransportError::failed(error.to_string()))?,
+ })
}
fn d_tag_from_uuid(uuid: Uuid) -> String {
@@ -8029,6 +8116,7 @@ fn buyer_order_request_local_work_payload(
"buyer_email": order.buyer_email,
"buyer_phone": order.buyer_phone,
"buyer_order_note": order.buyer_order_note,
+ "buyer_order_note_public_confirmed": order.buyer_order_note_public_confirmed,
"fulfillment": {
"window_id": order.fulfillment_window_id.map(|id| id.to_string()),
"label": order.fulfillment_window_label,
@@ -10454,6 +10542,7 @@ mod tests {
currency_code: Some("USD".to_owned()),
total_minor_units: Some(500),
note: Some("coordinate pickup".to_owned()),
+ confirm_public_note: true,
});
let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z")
.expect("typed order request publish work should serialize");
@@ -10501,6 +10590,7 @@ mod tests {
bin_count: 2,
}],
},
+ confirm_public_note: false,
});
let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z")
.expect("typed order decision publish work should serialize");
@@ -10597,6 +10687,7 @@ mod tests {
}],
economics: revision_economics,
reason: "harvest count updated".to_owned(),
+ confirm_public_note: false,
});
let revision_decision =
AppPublishPayload::OrderRevisionDecision(AppOrderRevisionDecisionPublishPayload {
@@ -10613,6 +10704,7 @@ mod tests {
buyer_pubkey: common.5.clone(),
seller_pubkey: common.6.clone(),
decision: RadrootsOrderRevisionOutcome::Accepted,
+ confirm_public_note: false,
});
let cancellation =
AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload {
@@ -10628,6 +10720,7 @@ mod tests {
buyer_pubkey: common.5.clone(),
seller_pubkey: common.6.clone(),
reason: "buyer cancelled order".to_owned(),
+ confirm_public_note: false,
});
let operations = [revision_proposal, revision_decision, cancellation]
.into_iter()
@@ -11182,6 +11275,7 @@ mod tests {
currency_code: Some("USD".to_owned()),
total_minor_units: Some(450),
note: None,
+ confirm_public_note: false,
});
let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-25T07:00:00Z")
.expect("order publish payload should serialize");
@@ -15084,6 +15178,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: "555-0101".to_owned(),
order_note: "Leave by the cooler".to_owned(),
+ confirm_public_note: true,
})
.expect("buyer order review draft should save")
);
@@ -15216,6 +15311,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: "555-0101".to_owned(),
order_note: "Leave by the cooler".to_owned(),
+ confirm_public_note: true,
})
.expect("buyer order review draft should save")
);
@@ -15328,7 +15424,7 @@ mod tests {
configure_runtime_relay_ingest(&runtime, &relay);
let payload = runtime
- .prepare_order_decline(order_id, " out of stock ")
+ .prepare_order_decline(order_id, " out of stock ", true)
.expect("seller order decline payload should prepare");
let decision = trade_decision_from_publish_payload(&payload)
.expect("order decline payload should convert to SDK trade decision");
@@ -15341,6 +15437,7 @@ mod tests {
reason: "out of stock".to_owned()
}
);
+ assert!(payload.confirm_public_note);
let AppSdkTradeDecision::Decline { reason } = decision else {
panic!("expected declined decision");
};
@@ -15528,7 +15625,7 @@ mod tests {
assert!(
runtime
- .publish_order_decline(order_id, "not available")
+ .publish_order_decline(order_id, "not available", true)
.expect("seller order decline should publish")
);
@@ -15584,6 +15681,7 @@ mod tests {
revision_test_order_items(),
revision_test_order_economics(),
"harvest count updated",
+ false,
)
.expect_err("seller revision proposal should reject reducer-invalid parent evidence");
@@ -15655,6 +15753,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: "555-0101".to_owned(),
order_note: "Leave by the cooler".to_owned(),
+ confirm_public_note: true,
})
.expect("buyer order review draft should save")
);
@@ -15794,6 +15893,10 @@ mod tests {
"Leave by the cooler"
);
assert_eq!(
+ payload["app_order"]["buyer_order_note_public_confirmed"],
+ true
+ );
+ assert_eq!(
payload["app_order"]["lines"][0]["listing_bin_id"],
"dozen-eggs"
);
@@ -16081,6 +16184,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: String::new(),
order_note: String::new(),
+ confirm_public_note: false,
})
.expect("buyer order review draft should save")
);
@@ -16191,7 +16295,7 @@ mod tests {
assert!(
fixture
.runtime
- .publish_buyer_order_cancel(fixture.order_id)
+ .publish_buyer_order_cancel(fixture.order_id, true)
.expect("linked buyer cancellation should publish")
);
@@ -16241,7 +16345,7 @@ mod tests {
let error = fixture
.runtime
- .publish_buyer_order_cancel(fixture.order_id)
+ .publish_buyer_order_cancel(fixture.order_id, false)
.expect_err("linked buyer cancellation should reject from pending proposal");
assert_invalid_projection_reason(
@@ -16274,7 +16378,7 @@ mod tests {
let error = fixture
.runtime
- .publish_buyer_order_cancel(fixture.order_id)
+ .publish_buyer_order_cancel(fixture.order_id, false)
.expect_err("post-agreement buyer cancellation should reject");
assert_invalid_projection_reason(
@@ -16327,7 +16431,7 @@ mod tests {
let error = fixture
.runtime
- .publish_buyer_order_cancel(fixture.order_id)
+ .publish_buyer_order_cancel(fixture.order_id, false)
.expect_err("linked buyer cancellation should reject reducer-invalid evidence");
assert_order_lifecycle_evidence_invalid(error);
@@ -16464,6 +16568,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: String::new(),
order_note: String::new(),
+ confirm_public_note: false,
})
.expect("buyer order review draft should save")
);
@@ -19954,6 +20059,7 @@ mod tests {
bin_count: 2,
}],
},
+ confirm_public_note: false,
});
let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z")
.expect("prior order decision publish work should serialize");
@@ -21032,6 +21138,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: String::new(),
order_note: String::new(),
+ confirm_public_note: false,
})
.expect("buyer order review draft should save")
);
diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs
@@ -78,6 +78,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"buyer-cart-open-order-review",
"buyer-cart-remove-line",
"buyer-order-review-back",
+ "buyer-order-review-confirm-public-note",
"buyer-order-review-place-order",
"buyer-listing-open",
"buyer-order-accept-change",
@@ -91,6 +92,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"personal_orders",
"buyer.add_to_cart_failed",
"buyer.cart_remove_failed",
+ "buyer.order_review_confirmation_save_failed",
"buyer.order_review_place_failed",
"buyer.order_review_save_failed",
"buyer.detail_open_failed",
@@ -123,6 +125,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"failed to remove buyer cart line",
"failed to reorder buyer order",
"failed to save buyer order review draft",
+ "failed to save buyer order review public note confirmation",
"failed to select buyer section",
"failed to open buyer product detail",
"failed to update buyer fulfillment filter",
@@ -671,6 +674,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::PersonalOrderReviewFieldEmail",
"AppTextKey::PersonalOrderReviewFieldPhone",
"AppTextKey::PersonalOrderReviewFieldOrderNote",
+ "AppTextKey::PersonalOrderReviewConfirmPublicNote",
"AppTextKey::PersonalOrderReviewLocalOnlyBody",
"AppTextKey::PersonalOrderReviewPlaceOrderAction",
"AppTextKey::HomeTodayOpenInOrdersAction",
@@ -1550,6 +1554,41 @@ fn app_production_sdk_boundary_usage_is_exception_scoped() {
}
#[test]
+fn app_sdk_trade_propose_request_stays_product_shaped() {
+ let source = read_source_path(app_root().join("crates/runtime/src/sdk.rs").as_path());
+ let request = struct_block(source.as_str(), "AppSdkTradeProposeRequest");
+
+ assert!(
+ !request.contains("RadrootsOrderRequest"),
+ "AppSdkTradeProposeRequest must not expose protocol-shaped order requests"
+ );
+ for required_field in [
+ "pub order_id: RadrootsOrderId",
+ "pub listing_addr: RadrootsListingAddress",
+ "pub seller_pubkey: RadrootsPublicKey",
+ "pub items: Vec<RadrootsOrderItem>",
+ "pub economics: RadrootsOrderEconomics",
+ "pub public_note: Option<String>",
+ "pub confirm_public_note: bool",
+ ] {
+ assert!(
+ request.contains(required_field),
+ "AppSdkTradeProposeRequest is missing product field `{required_field}`"
+ );
+ }
+ assert!(source.contains("fn app_trade_privacy_confirmation(confirm_public_note: bool)"));
+ assert!(source.contains("if confirm_public_note"));
+ assert!(source.contains(".with_optional_public_note(request.public_note)"));
+ assert!(source.contains(
+ ".with_privacy_confirmation(app_trade_privacy_confirmation(request.confirm_public_note))"
+ ));
+ assert!(
+ !source.contains("fn app_trade_privacy_confirmation()"),
+ "public-sensitive note confirmation must not become ambient"
+ );
+}
+
+#[test]
fn app_production_sources_do_not_suppress_dead_code() {
let forbidden = ["#[allow(", "dead_code", ")]"].concat();
@@ -2312,6 +2351,30 @@ fn read_source_path(path: &Path) -> String {
.unwrap_or_else(|error| panic!("failed to read source {}: {error}", path.display()))
}
+fn struct_block<'source>(source: &'source str, struct_name: &str) -> &'source str {
+ let start = source
+ .find(format!("struct {struct_name}").as_str())
+ .unwrap_or_else(|| panic!("missing struct `{struct_name}`"));
+ let open = source[start..]
+ .find('{')
+ .map(|index| start + index)
+ .unwrap_or_else(|| panic!("missing struct `{struct_name}` body"));
+ let mut depth = 0usize;
+ for (offset, character) in source[open..].char_indices() {
+ match character {
+ '{' => depth += 1,
+ '}' => {
+ depth -= 1;
+ if depth == 0 {
+ return &source[start..=open + offset];
+ }
+ }
+ _ => {}
+ }
+ }
+ panic!("struct `{struct_name}` body is not closed");
+}
+
fn app_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs
@@ -2092,6 +2092,35 @@ impl HomeView {
}
}
+ fn set_buyer_order_review_public_note_confirmation(
+ &mut self,
+ confirmed: bool,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(form) = self.buyer_order_review_form.as_mut() else {
+ return;
+ };
+ if form.confirm_public_note == confirmed {
+ return;
+ }
+ form.confirm_public_note = confirmed;
+ match self
+ .runtime
+ .save_personal_order_review_draft(form.current_draft(cx))
+ {
+ Ok(true) => cx.notify(),
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "buyer",
+ event = "buyer.order_review_confirmation_save_failed",
+ error = %runtime_error,
+ "failed to save buyer order review public note confirmation"
+ );
+ }
+ }
+ }
+
fn toggle_personal_search_fulfillment_method(
&mut self,
method: FarmOrderMethod,
@@ -2866,7 +2895,7 @@ impl HomeView {
}
fn cancel_buyer_order(&mut self, order_id: OrderId, cx: &mut Context<Self>) {
- match self.runtime.publish_buyer_order_cancel(order_id) {
+ match self.runtime.publish_buyer_order_cancel(order_id, false) {
Ok(true) => cx.notify(),
Ok(false) => {}
Err(runtime_error) => {
@@ -2898,7 +2927,10 @@ impl HomeView {
}
fn decline_buyer_order_revision(&mut self, order_id: OrderId, cx: &mut Context<Self>) {
- match self.runtime.publish_buyer_order_revision_decline(order_id) {
+ match self
+ .runtime
+ .publish_buyer_order_revision_decline(order_id, false)
+ {
Ok(true) => cx.notify(),
Ok(false) => {}
Err(runtime_error) => {
@@ -3618,6 +3650,9 @@ impl HomeView {
form,
&runtime.personal_projection.cart.order_review,
cx.listener(|this, _, _, cx| this.close_personal_order_review(cx)),
+ cx.listener(|this, checked: &bool, _, cx| {
+ this.set_buyer_order_review_public_note_confirmation(*checked, cx)
+ }),
cx.listener(|this, _, _, cx| this.place_personal_order(cx)),
cx,
)
@@ -5339,6 +5374,7 @@ struct BuyerOrderReviewFormState {
email_input: Entity<InputState>,
phone_input: Entity<InputState>,
order_note_input: Entity<InputState>,
+ confirm_public_note: bool,
_name_subscription: Subscription,
_email_subscription: Subscription,
_phone_subscription: Subscription,
@@ -5386,6 +5422,7 @@ impl BuyerOrderReviewFormState {
email_input,
phone_input,
order_note_input,
+ confirm_public_note: draft.confirm_public_note,
_name_subscription: name_subscription,
_email_subscription: email_subscription,
_phone_subscription: phone_subscription,
@@ -5408,6 +5445,7 @@ impl BuyerOrderReviewFormState {
window,
cx,
);
+ self.confirm_public_note = draft.confirm_public_note;
}
fn current_draft(&self, cx: &App) -> BuyerOrderReviewDraft {
@@ -5416,6 +5454,7 @@ impl BuyerOrderReviewFormState {
email: self.email_input.read(cx).value().to_string(),
phone: self.phone_input.read(cx).value().to_string(),
order_note: self.order_note_input.read(cx).value().to_string(),
+ confirm_public_note: self.confirm_public_note,
}
}
}
@@ -11388,6 +11427,7 @@ fn buyer_order_review_card(
form: &BuyerOrderReviewFormState,
order_review: &radroots_studio_app_view::BuyerOrderReviewProjection,
on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_confirm_public_note_change: impl Fn(&bool, &mut Window, &mut App) + 'static,
on_place_order: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
cx: &App,
) -> impl IntoElement {
@@ -11478,6 +11518,18 @@ fn buyer_order_review_card(
),
&form.order_note_input,
false,
+ ))
+ .child(app_checkbox_field(
+ AppCheckboxFieldSpec::new(
+ "buyer-order-review-confirm-public-note",
+ app_shared_text(AppTextKey::PersonalOrderReviewConfirmPublicNote),
+ Option::<SharedString>::None,
+ ),
+ form.confirm_public_note,
+ cx,
+ move |checked, window, cx| {
+ on_confirm_public_note_change(&checked, window, cx)
+ },
)),
))
.child(if order_review.can_place_order {
diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs
@@ -378,6 +378,7 @@ define_app_text_keys! {
PersonalOrderReviewFieldEmail => "personal.order_review.field.email",
PersonalOrderReviewFieldPhone => "personal.order_review.field.phone",
PersonalOrderReviewFieldOrderNote => "personal.order_review.field.order_note",
+ PersonalOrderReviewConfirmPublicNote => "personal.order_review.confirm_public_note",
PersonalOrderReviewLocalOnlyBody => "personal.order_review.local_only.body",
PersonalOrderReviewPlaceOrderAction => "personal.order_review.place_order.action",
OrdersTitle => "orders.title",
diff --git a/crates/runtime/src/sdk.rs b/crates/runtime/src/sdk.rs
@@ -15,12 +15,15 @@ use radroots_events::{
RadrootsNostrEventPtr,
contract::RadrootsActorRole,
farm::RadrootsFarm,
- ids::{RadrootsAddressableCoordinate, RadrootsOrderRevisionId},
+ ids::{
+ RadrootsAddressableCoordinate, RadrootsListingAddress, RadrootsOrderId,
+ RadrootsOrderRevisionId, RadrootsPublicKey,
+ },
kinds::KIND_FARM,
listing::RadrootsListing,
order::{
RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, RadrootsOrderItem,
- RadrootsOrderRequest, RadrootsOrderRevisionOutcome,
+ RadrootsOrderRevisionOutcome,
},
};
use radroots_nostr::prelude::RadrootsNostrKeys;
@@ -249,7 +252,13 @@ pub struct AppSdkTradeProposeRequest {
pub actor_pubkey: String,
pub signer_keys: RadrootsNostrKeys,
pub listing_event: RadrootsNostrEventPtr,
- pub order: RadrootsOrderRequest,
+ pub order_id: RadrootsOrderId,
+ pub listing_addr: RadrootsListingAddress,
+ pub seller_pubkey: RadrootsPublicKey,
+ pub items: Vec<RadrootsOrderItem>,
+ pub economics: RadrootsOrderEconomics,
+ pub public_note: Option<String>,
+ pub confirm_public_note: bool,
pub idempotency_key: Option<String>,
}
@@ -268,6 +277,7 @@ pub struct AppSdkTradeDecisionRequest {
pub signer_keys: RadrootsNostrKeys,
pub locator: RadrootsTradeLocator,
pub decision: AppSdkTradeDecision,
+ pub confirm_public_note: bool,
pub idempotency_key: Option<String>,
}
@@ -280,6 +290,7 @@ pub struct AppSdkTradeRevisionProposalRequest {
pub items: Vec<RadrootsOrderItem>,
pub economics: RadrootsOrderEconomics,
pub reason: String,
+ pub confirm_public_note: bool,
pub idempotency_key: Option<String>,
}
@@ -290,6 +301,7 @@ pub struct AppSdkTradeRevisionDecisionRequest {
pub locator: RadrootsTradeLocator,
pub revision_id: RadrootsOrderRevisionId,
pub decision: RadrootsOrderRevisionOutcome,
+ pub confirm_public_note: bool,
pub idempotency_key: Option<String>,
}
@@ -299,6 +311,7 @@ pub struct AppSdkTradeCancellationRequest {
pub signer_keys: RadrootsNostrKeys,
pub locator: RadrootsTradeLocator,
pub reason: String,
+ pub confirm_public_note: bool,
pub idempotency_key: Option<String>,
}
@@ -1396,8 +1409,13 @@ fn app_trade_relay_resolution_policy() -> RelayResolutionPolicy {
RelayResolutionPolicy::configured_relays()
}
-fn app_trade_privacy_confirmation() -> PrivacyPreflightConfirmation {
- PrivacyPreflightConfirmation::new().confirm(ProductSensitivityField::PublicButSensitiveNotes)
+fn app_trade_privacy_confirmation(confirm_public_note: bool) -> PrivacyPreflightConfirmation {
+ if confirm_public_note {
+ PrivacyPreflightConfirmation::new()
+ .confirm(ProductSensitivityField::PublicButSensitiveNotes)
+ } else {
+ PrivacyPreflightConfirmation::new()
+ }
}
fn run_restore_preflight(
@@ -1541,12 +1559,17 @@ fn trade_propose_with_sdk(
let mut sdk_request = TradeProposeRequest::new(
actor,
request.listing_event,
- request.order,
+ request.order_id,
+ request.listing_addr,
+ request.seller_pubkey,
+ request.items,
+ request.economics,
app_trade_relay_resolution_policy(),
app_trade_publish_mode(),
app_trade_ack_policy(),
)
- .with_privacy_confirmation(app_trade_privacy_confirmation());
+ .with_optional_public_note(request.public_note)
+ .with_privacy_confirmation(app_trade_privacy_confirmation(request.confirm_public_note));
if let Some(idempotency_key) = request.idempotency_key.as_deref() {
sdk_request = sdk_request
.try_with_idempotency_key(idempotency_key)
@@ -1584,7 +1607,7 @@ fn trade_decision_with_sdk(
ack_policy,
TradeEvidenceMode::ResyncBeforeMutation,
)
- .with_privacy_confirmation(app_trade_privacy_confirmation());
+ .with_privacy_confirmation(app_trade_privacy_confirmation(false));
if let Some(idempotency_key) = request.idempotency_key.as_deref() {
sdk_request = sdk_request
.try_with_idempotency_key(idempotency_key)
@@ -1604,7 +1627,7 @@ fn trade_decision_with_sdk(
ack_policy,
TradeEvidenceMode::ResyncBeforeMutation,
)
- .with_privacy_confirmation(app_trade_privacy_confirmation());
+ .with_privacy_confirmation(app_trade_privacy_confirmation(request.confirm_public_note));
if let Some(idempotency_key) = request.idempotency_key.as_deref() {
sdk_request = sdk_request
.try_with_idempotency_key(idempotency_key)
@@ -1641,7 +1664,7 @@ fn trade_revision_propose_with_sdk(
app_trade_ack_policy(),
TradeEvidenceMode::ResyncBeforeMutation,
)
- .with_privacy_confirmation(app_trade_privacy_confirmation());
+ .with_privacy_confirmation(app_trade_privacy_confirmation(request.confirm_public_note));
if let Some(idempotency_key) = request.idempotency_key.as_deref() {
sdk_request = sdk_request
.try_with_idempotency_key(idempotency_key)
@@ -1674,7 +1697,7 @@ fn trade_revision_decide_with_sdk(
app_trade_ack_policy(),
TradeEvidenceMode::ResyncBeforeMutation,
)
- .with_privacy_confirmation(app_trade_privacy_confirmation());
+ .with_privacy_confirmation(app_trade_privacy_confirmation(request.confirm_public_note));
if let Some(idempotency_key) = request.idempotency_key.as_deref() {
sdk_request = sdk_request
.try_with_idempotency_key(idempotency_key)
@@ -1711,7 +1734,7 @@ fn trade_cancel_with_sdk(
app_trade_ack_policy(),
TradeEvidenceMode::ResyncBeforeMutation,
)
- .with_privacy_confirmation(app_trade_privacy_confirmation());
+ .with_privacy_confirmation(app_trade_privacy_confirmation(request.confirm_public_note));
if let Some(idempotency_key) = request.idempotency_key.as_deref() {
sdk_request = sdk_request
.try_with_idempotency_key(idempotency_key)
diff --git a/crates/store/migrations/0030_buyer_order_public_note_confirmation.sql b/crates/store/migrations/0030_buyer_order_public_note_confirmation.sql
@@ -0,0 +1,5 @@
+alter table buyer_carts
+ add column buyer_order_note_public_confirmed integer not null default 0;
+
+alter table orders
+ add column buyer_order_note_public_confirmed integer not null default 0;
diff --git a/crates/store/src/interop.rs b/crates/store/src/interop.rs
@@ -7311,6 +7311,7 @@ mod tests {
email: "casey@example.test".to_owned(),
phone: String::new(),
order_note: String::new(),
+ confirm_public_note: false,
},
)
.expect("order review draft should save");
diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs
@@ -903,6 +903,11 @@ mod tests {
assert!(column_exists(connection, "buyer_carts", "buyer_order_note"));
assert!(column_exists(
connection,
+ "buyer_carts",
+ "buyer_order_note_public_confirmed"
+ ));
+ assert!(column_exists(
+ connection,
"buyer_cart_lines",
"listing_bin_id"
));
@@ -933,6 +938,11 @@ mod tests {
assert!(column_exists(connection, "orders", "buyer_order_note"));
assert!(column_exists(
connection,
+ "orders",
+ "buyer_order_note_public_confirmed"
+ ));
+ assert!(column_exists(
+ connection,
"reminder_schedules",
"account_id"
));
diff --git a/crates/store/src/migrations.rs b/crates/store/src/migrations.rs
@@ -122,6 +122,10 @@ const MIGRATIONS: &[Migration] = &[
version: 29,
sql: include_str!("../migrations/0029_order_workflow_validation_receipt_agreement.sql"),
},
+ Migration {
+ version: 30,
+ sql: include_str!("../migrations/0030_buyer_order_public_note_confirmation.sql"),
+ },
];
pub fn latest_schema_version() -> u32 {
diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs
@@ -91,6 +91,7 @@ pub struct BuyerOrderLocalEventExport {
pub buyer_email: String,
pub buyer_phone: String,
pub buyer_order_note: String,
+ pub buyer_order_note_public_confirmed: bool,
pub updated_at: String,
pub fulfillment_window_id: Option<FulfillmentWindowId>,
pub fulfillment_window_label: Option<String>,
@@ -382,6 +383,7 @@ impl<'a> AppBuyerRepository<'a> {
buyer_email = ?3,
buyer_phone = ?4,
buyer_order_note = ?5,
+ buyer_order_note_public_confirmed = ?6,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
where buyer_context_key = ?1",
params![
@@ -390,6 +392,7 @@ impl<'a> AppBuyerRepository<'a> {
draft.email.trim(),
draft.phone.trim(),
draft.order_note.trim(),
+ draft.confirm_public_note,
],
)
.map_err(|source| AppSqliteError::Query {
@@ -445,7 +448,8 @@ impl<'a> AppBuyerRepository<'a> {
buyer_context_key,
buyer_email,
buyer_phone,
- buyer_order_note
+ buyer_order_note,
+ buyer_order_note_public_confirmed
) values (
?1,
?2,
@@ -457,7 +461,8 @@ impl<'a> AppBuyerRepository<'a> {
?6,
?7,
?8,
- ?9
+ ?9,
+ ?10
)",
params![
order_id.to_string(),
@@ -469,6 +474,7 @@ impl<'a> AppBuyerRepository<'a> {
order_review.draft.email.trim(),
order_review.draft.phone.trim(),
order_review.draft.order_note.trim(),
+ order_review.draft.confirm_public_note,
],
)
.map_err(|source| AppSqliteError::Query {
@@ -536,6 +542,7 @@ impl<'a> AppBuyerRepository<'a> {
set
farm_id = null,
buyer_order_note = '',
+ buyer_order_note_public_confirmed = 0,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
where buyer_context_key = ?1",
params![context_key.as_str()],
@@ -1069,6 +1076,7 @@ impl<'a> AppBuyerRepository<'a> {
o.buyer_email,
o.buyer_phone,
o.buyer_order_note,
+ o.buyer_order_note_public_confirmed,
o.updated_at,
f.display_name,
fw.id,
@@ -1092,12 +1100,13 @@ impl<'a> AppBuyerRepository<'a> {
row.get::<_, String>(6)?,
row.get::<_, String>(7)?,
row.get::<_, String>(8)?,
- row.get::<_, String>(9)?,
+ row.get::<_, bool>(9)?,
row.get::<_, String>(10)?,
- row.get::<_, Option<String>>(11)?,
+ row.get::<_, String>(11)?,
row.get::<_, Option<String>>(12)?,
row.get::<_, Option<String>>(13)?,
row.get::<_, Option<String>>(14)?,
+ row.get::<_, Option<String>>(15)?,
))
},
)
@@ -1119,6 +1128,7 @@ impl<'a> AppBuyerRepository<'a> {
buyer_email,
buyer_phone,
buyer_order_note,
+ buyer_order_note_public_confirmed,
updated_at,
farm_display_name,
fulfillment_window_id,
@@ -1141,6 +1151,7 @@ impl<'a> AppBuyerRepository<'a> {
buyer_email,
buyer_phone,
buyer_order_note,
+ buyer_order_note_public_confirmed,
updated_at,
fulfillment_window_id: parse_optional_typed_id(
"orders.fulfillment_window_id",
@@ -1566,7 +1577,8 @@ impl<'a> AppBuyerRepository<'a> {
buyer_name,
buyer_email,
buyer_phone,
- buyer_order_note
+ buyer_order_note,
+ buyer_order_note_public_confirmed
from buyer_carts
where buyer_context_key = ?1
limit 1",
@@ -1578,6 +1590,7 @@ impl<'a> AppBuyerRepository<'a> {
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
+ row.get::<_, bool>(5)?,
))
},
)
@@ -1587,13 +1600,21 @@ impl<'a> AppBuyerRepository<'a> {
source,
})?
.map(
- |(farm_id, buyer_name, buyer_email, buyer_phone, buyer_order_note)| {
+ |(
+ farm_id,
+ buyer_name,
+ buyer_email,
+ buyer_phone,
+ buyer_order_note,
+ buyer_order_note_public_confirmed,
+ )| {
Ok(BuyerCartHeader {
farm_id: parse_optional_typed_id("buyer_carts.farm_id", farm_id)?,
buyer_name,
buyer_email,
buyer_phone,
buyer_order_note,
+ buyer_order_note_public_confirmed,
})
},
)
@@ -2204,6 +2225,7 @@ struct BuyerCartHeader {
buyer_email: String,
buyer_phone: String,
buyer_order_note: String,
+ buyer_order_note_public_confirmed: bool,
}
impl BuyerCartHeader {
@@ -2213,6 +2235,7 @@ impl BuyerCartHeader {
email: self.buyer_email,
phone: self.buyer_phone,
order_note: self.buyer_order_note,
+ confirm_public_note: self.buyer_order_note_public_confirmed,
}
}
}
@@ -2650,6 +2673,9 @@ fn buyer_order_review_disabled_reason(
if draft.email.trim().is_empty() {
return Some(BuyerOrderReviewDisabledReason::MissingEmail);
}
+ if !draft.order_note.trim().is_empty() && !draft.confirm_public_note {
+ return Some(BuyerOrderReviewDisabledReason::PublicNoteConfirmationRequired);
+ }
if matches!(context, BuyerContext::Guest) {
return Some(BuyerOrderReviewDisabledReason::AccountRequired);
}
@@ -2664,6 +2690,9 @@ fn buyer_order_review_disabled_error(reason: BuyerOrderReviewDisabledReason) ->
}
BuyerOrderReviewDisabledReason::MissingName => "buyer order review buyer name is missing",
BuyerOrderReviewDisabledReason::MissingEmail => "buyer order review buyer email is missing",
+ BuyerOrderReviewDisabledReason::PublicNoteConfirmationRequired => {
+ "buyer order review public note confirmation is required"
+ }
BuyerOrderReviewDisabledReason::AccountRequired => {
"buyer order review requires a selected account"
}
@@ -2944,8 +2973,9 @@ mod tests {
use std::collections::BTreeSet;
use radroots_studio_app_view::{
- BuyerContext, BuyerOrderReviewDisabledReason, BuyerOrderStatus, FarmId, FarmOrderMethod,
- FulfillmentWindowId, OrderId, PickupLocationId, ProductId, TradeAgreementStatus,
+ BuyerCartLineProjection, BuyerCartProjection, BuyerContext, BuyerOrderReviewDisabledReason,
+ BuyerOrderReviewDraft, BuyerOrderStatus, FarmId, FarmOrderMethod, FulfillmentWindowId,
+ OrderId, PickupLocationId, ProductId, ProductPricePresentation, TradeAgreementStatus,
TradeInventoryStatus, TradeRevisionStatus, TradeWorkflowSource,
};
use rusqlite::{Connection, params};
@@ -2962,6 +2992,60 @@ mod tests {
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
+ fn buyer_order_review_requires_public_note_confirmation() {
+ let cart = BuyerCartProjection {
+ farm_id: Some(FarmId::new()),
+ farm_display_name: Some("Willow Farm".to_owned()),
+ lines: vec![BuyerCartLineProjection {
+ product_id: ProductId::new(),
+ farm_id: FarmId::new(),
+ farm_display_name: "Willow Farm".to_owned(),
+ title: "Salad mix".to_owned(),
+ quantity: 1,
+ unit_price: ProductPricePresentation {
+ amount_minor_units: 650,
+ currency_code: "USD".to_owned(),
+ unit_label: "bag".to_owned(),
+ },
+ line_total_minor_units: 650,
+ fulfillment_summary: "Friday pickup".to_owned(),
+ }],
+ subtotal_minor_units: Some(650),
+ currency_code: Some("USD".to_owned()),
+ replace_confirmation: None,
+ };
+ let draft = BuyerOrderReviewDraft {
+ name: "Casey Buyer".to_owned(),
+ email: "casey@example.com".to_owned(),
+ phone: String::new(),
+ order_note: "Leave by the cooler".to_owned(),
+ confirm_public_note: false,
+ };
+
+ assert_eq!(
+ super::buyer_order_review_disabled_reason(
+ &BuyerContext::account("acct_buyer"),
+ &cart,
+ Some(&"Friday pickup".to_owned()),
+ &draft,
+ ),
+ Some(BuyerOrderReviewDisabledReason::PublicNoteConfirmationRequired)
+ );
+ assert_eq!(
+ super::buyer_order_review_disabled_reason(
+ &BuyerContext::account("acct_buyer"),
+ &cart,
+ Some(&"Friday pickup".to_owned()),
+ &BuyerOrderReviewDraft {
+ confirm_public_note: true,
+ ..draft
+ },
+ ),
+ None
+ );
+ }
+
+ #[test]
fn selected_buyer_order_scope_uses_only_valid_context_keys() {
let upper_pubkey = LINKED_BUYER_PUBKEY.to_ascii_uppercase();
let valid_scope = SelectedBuyerOrderScope::for_selected_account(
@@ -3265,6 +3349,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: "555-0101".to_owned(),
order_note: "Leave by the cooler".to_owned(),
+ confirm_public_note: true,
},
)
.expect("buyer order review draft should save");
@@ -3395,6 +3480,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: String::new(),
order_note: String::new(),
+ confirm_public_note: false,
},
)
.expect("buyer order review draft should save");
@@ -3521,6 +3607,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: String::new(),
order_note: String::new(),
+ confirm_public_note: false,
},
)
.expect("buyer order review draft should save");
@@ -3635,6 +3722,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: String::new(),
order_note: String::new(),
+ confirm_public_note: false,
},
)
.expect("buyer order review draft should save");
diff --git a/crates/sync/src/publish.rs b/crates/sync/src/publish.rs
@@ -137,6 +137,7 @@ pub struct AppOrderRequestPublishPayload {
pub currency_code: Option<String>,
pub total_minor_units: Option<u32>,
pub note: Option<String>,
+ pub confirm_public_note: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
@@ -177,6 +178,7 @@ pub struct AppOrderDecisionPublishPayload {
pub buyer_pubkey: String,
pub seller_pubkey: String,
pub decision: AppOrderDecisionPayload,
+ pub confirm_public_note: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
@@ -193,6 +195,7 @@ pub struct AppOrderRevisionProposalPublishPayload {
pub items: Vec<RadrootsOrderItem>,
pub economics: RadrootsOrderEconomics,
pub reason: String,
+ pub confirm_public_note: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
@@ -207,6 +210,7 @@ pub struct AppOrderRevisionDecisionPublishPayload {
pub buyer_pubkey: String,
pub seller_pubkey: String,
pub decision: RadrootsOrderRevisionOutcome,
+ pub confirm_public_note: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
@@ -220,6 +224,7 @@ pub struct AppOrderCancellationPublishPayload {
pub buyer_pubkey: String,
pub seller_pubkey: String,
pub reason: String,
+ pub confirm_public_note: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
@@ -800,6 +805,7 @@ mod tests {
currency_code: None,
total_minor_units: None,
note: None,
+ confirm_public_note: false,
});
let reason_codes: Vec<&str> = payload
@@ -839,6 +845,7 @@ mod tests {
decision: AppOrderDecisionPayload::Declined {
reason: " ".to_owned(),
},
+ confirm_public_note: false,
});
assert_eq!(payload.work_kind().storage_key(), "order_decision");
@@ -882,6 +889,7 @@ mod tests {
buyer_pubkey: String::new(),
seller_pubkey: String::new(),
reason: " ".to_owned(),
+ confirm_public_note: false,
});
assert_eq!(
@@ -920,6 +928,7 @@ mod tests {
buyer_pubkey: "buyer".to_owned(),
seller_pubkey: "seller".to_owned(),
reason: "buyer cancelled order".to_owned(),
+ confirm_public_note: false,
}),
"2026-04-20T18:00:00Z",
)
@@ -940,6 +949,7 @@ mod tests {
buyer_pubkey: "buyer".to_owned(),
seller_pubkey: "seller".to_owned(),
reason: "buyer cancelled order".to_owned(),
+ confirm_public_note: false,
})
);
}
@@ -966,6 +976,7 @@ mod tests {
}],
economics: economics.clone(),
reason: "harvest count updated".to_owned(),
+ confirm_public_note: false,
});
let invalid_proposal =
AppPublishPayload::OrderRevisionProposal(AppOrderRevisionProposalPublishPayload {
@@ -981,6 +992,7 @@ mod tests {
items: Vec::new(),
economics: economics.clone(),
reason: " ".to_owned(),
+ confirm_public_note: false,
});
let invalid_decision =
AppPublishPayload::OrderRevisionDecision(AppOrderRevisionDecisionPublishPayload {
@@ -996,6 +1008,7 @@ mod tests {
decision: RadrootsOrderRevisionOutcome::Declined {
reason: " ".to_owned(),
},
+ confirm_public_note: false,
});
assert_eq!(
diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs
@@ -1027,6 +1027,7 @@ pub struct BuyerOrderReviewDraft {
pub email: String,
pub phone: String,
pub order_note: String,
+ pub confirm_public_note: bool,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
@@ -1045,6 +1046,7 @@ pub enum BuyerOrderReviewDisabledReason {
MissingFulfillment,
MissingName,
MissingEmail,
+ PublicNoteConfirmationRequired,
AccountRequired,
}
@@ -1055,6 +1057,7 @@ impl BuyerOrderReviewDisabledReason {
Self::MissingFulfillment => "missing_fulfillment",
Self::MissingName => "missing_name",
Self::MissingEmail => "missing_email",
+ Self::PublicNoteConfirmationRequired => "public_note_confirmation_required",
Self::AccountRequired => "account_required",
}
}
@@ -2648,6 +2651,10 @@ mod tests {
"missing_email"
);
assert_eq!(
+ BuyerOrderReviewDisabledReason::PublicNoteConfirmationRequired.storage_key(),
+ "public_note_confirmation_required"
+ );
+ assert_eq!(
BuyerOrderReviewDisabledReason::AccountRequired.storage_key(),
"account_required"
);
@@ -3586,6 +3593,7 @@ mod tests {
email: "casey@example.com".to_owned(),
phone: String::new(),
order_note: "Leave by the cooler".to_owned(),
+ confirm_public_note: true,
},
summary: BuyerOrderReviewSummaryProjection {
farm_display_name: Some("Cedar Grove Farm".to_owned()),
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -358,6 +358,7 @@
"personal.order_review.field.email": "Email",
"personal.order_review.field.phone": "Phone",
"personal.order_review.field.order_note": "Order note",
+ "personal.order_review.confirm_public_note": "Publish this order note with the request",
"personal.order_review.local_only.body": "Review the details before placing the order.",
"personal.order_review.place_order.action": "Place order",
"orders.title": "Orders",