app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mcrates/desktop/src/runtime.rs | 159++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/desktop/src/source_guards.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/desktop/src/window.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/i18n/src/keys.rs | 1+
Mcrates/runtime/src/sdk.rs | 47+++++++++++++++++++++++++++++++++++------------
Acrates/store/migrations/0030_buyer_order_public_note_confirmation.sql | 5+++++
Mcrates/store/src/interop.rs | 1+
Mcrates/store/src/lib.rs | 10++++++++++
Mcrates/store/src/migrations.rs | 4++++
Mcrates/store/src/repo/buyer.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/sync/src/publish.rs | 13+++++++++++++
Mcrates/view/src/lib.rs | 8++++++++
Mi18n/locales/en/messages.json | 1+
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",