app

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

commit 4c8d53996f02f0dbfa345741f0c80d6a1ddd1c81
parent 9ba9b1c16811a139c080274eb6a67f283b27c0a6
Author: triesap <tyson@radroots.org>
Date:   Mon, 25 May 2026 00:39:07 +0000

sync: harden listing publishability

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 617+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/launchers/desktop/src/source_guards.rs | 3+++
Mcrates/launchers/desktop/src/window.rs | 22++++++++++++++++++++++
Mcrates/shared/i18n/src/keys.rs | 3+++
Mcrates/shared/i18n/src/lib.rs | 12++++++++++++
Mcrates/shared/models/src/lib.rs | 17+++++++++++++++++
Acrates/shared/sqlite/migrations/0017_product_category.sql | 1+
Mcrates/shared/sqlite/src/lib.rs | 1+
Mcrates/shared/sqlite/src/migrations.rs | 4++++
Mcrates/shared/sqlite/src/products.rs | 64+++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/shared/state/src/lib.rs | 3+++
Mcrates/shared/sync/src/publish.rs | 15+++++++++++++++
Mi18n/locales/en/messages.json | 3+++
13 files changed, 578 insertions(+), 187 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -45,10 +45,11 @@ use radroots_app_state::{ FarmSetupFlowStage, FarmWorkspaceReadinessProjection, HomeRoute, OrdersScreenProjection, PackDayBatchPrintRequest, PackDayExportRequest, PackDayHostHandoffRequest, PackDayPrintRequest, PackDayScreenProjection, PersistedAppState, PersonalWorkspaceProjection, - ProductsScreenProjection, ProductsScreenQueryState, derive_sync_projection, + ProductsScreenProjection, ProductsScreenQueryState, derive_product_publish_blockers, + derive_sync_projection, }; use radroots_app_sync::{ - AppListingPublishPayload, AppOrderRequestPublishPayload, AppPublishPayload, + AppListingPublishPayload, AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload, AppPublishedOperationReceipt, AppSyncProjection, AppSyncRequest, AppSyncResult, AppSyncTransport, AppSyncTransportError, PendingSyncOperation, SyncAggregateRef, SyncCheckpointStatus, SyncConflictSeverity, SyncOperationKind, SyncTrigger, @@ -2515,7 +2516,7 @@ impl DesktopAppRuntimeState { let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(false); }; - let Some(farm_id) = self.selected_farm_id() else { + let Some(_) = self.selected_farm_id() else { return Ok(false); }; if self @@ -2540,20 +2541,13 @@ impl DesktopAppRuntimeState { &continuity_state, )?; let context_changed = self.apply_selected_account_context(&selected_account_context); - let pending_changed = - self.enqueue_selected_account_sync_operations(vec![pending_sync_upsert( - SyncAggregateRef::Product(product_id), - product_sync_payload( - product_id, - Some(farm_id), - "update_product_stock", - None, - Some(stock_quantity), - None, - ), - )])?; + let publish_changed = self.enqueue_selected_account_product_publish_operation( + product_id, + "update_product_stock", + None, + )?; - Ok(updated || context_changed || pending_changed) + Ok(updated || context_changed || publish_changed) } fn open_new_product_editor(&mut self) -> Result<bool, AppSqliteError> { @@ -2576,26 +2570,13 @@ impl DesktopAppRuntimeState { )?; let context_changed = self.apply_selected_account_context(&selected_account_context); let section_changed = self.select_farmer_section(FarmerSection::Products); - let draft_payload = draft.clone(); let editor_changed = self.state_store .apply_in_memory(AppStateCommand::open_existing_product_editor( product_id, draft, )); - let pending_changed = - self.enqueue_selected_account_sync_operations(vec![pending_sync_upsert( - SyncAggregateRef::Product(product_id), - product_sync_payload( - product_id, - Some(farm_id), - "open_new_product_editor", - Some(&draft_payload), - draft_payload.stock_quantity, - None, - ), - )])?; - Ok(context_changed || section_changed || editor_changed || pending_changed) + Ok(context_changed || section_changed || editor_changed) } fn open_existing_product_editor( @@ -2662,22 +2643,19 @@ impl DesktopAppRuntimeState { .apply_in_memory(AppStateCommand::replace_product_editor_draft( reloaded_draft, )); - let app_local_changed = + let source_local_event_id = self.append_app_listing_local_work_record(product_id, &draft_payload)?; - let pending_changed = - self.enqueue_selected_account_sync_operations(vec![pending_sync_upsert( - SyncAggregateRef::Product(product_id), - product_sync_payload( - product_id, - self.selected_farm_id(), - "save_product_editor_draft", - Some(&draft_payload), - draft_payload.stock_quantity, - None, - ), - )])?; + let pending_changed = self.enqueue_selected_account_product_publish_operation( + product_id, + "save_product_editor_draft", + source_local_event_id.as_deref(), + )?; - Ok(saved || context_changed || editor_changed || app_local_changed || pending_changed) + Ok(saved + || context_changed + || editor_changed + || source_local_event_id.is_some() + || pending_changed) } fn close_product_editor(&mut self) -> bool { @@ -3440,6 +3418,102 @@ impl DesktopAppRuntimeState { self.enqueue_selected_account_sync_operations(vec![operation]) } + fn enqueue_selected_account_product_publish_operation( + &mut self, + product_id: ProductId, + source: &str, + source_local_event_id: Option<&str>, + ) -> Result<bool, AppSqliteError> { + let Some(operation) = + self.product_publish_operation(product_id, source, source_local_event_id)? + else { + return self.refresh_selected_account_sync(); + }; + + self.enqueue_selected_account_sync_operations(vec![operation]) + } + + fn product_publish_operation( + &self, + product_id: ProductId, + source: &str, + source_local_event_id: Option<&str>, + ) -> Result<Option<PendingSyncOperation>, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(None); + }; + let Some(selected_account) = self + .state_store + .identity_projection() + .selected_account + .as_ref() + else { + return Ok(None); + }; + let Some(draft) = sqlite_store.load_product_editor_draft(product_id)? else { + return Ok(None); + }; + if !product_status_needs_relay_publish(draft.status) { + return Ok(None); + } + if !derive_product_publish_blockers(&draft, self.state_store.farm_readiness_projection()) + .is_empty() + { + return Ok(None); + } + let Some(farm_id) = self.selected_farm_id() else { + return Ok(None); + }; + let Some(farm_pubkey) = self.local_events_owner_pubkey(selected_account) else { + return Ok(None); + }; + let farm_setup = self.state_store.farm_setup_projection(); + let listing_d_tag = d_tag_from_uuid(product_id.as_uuid()); + let mut context = AppPublishContext::new( + selected_account.account.account_id.clone(), + source.to_owned(), + ); + if let Some(source_local_event_id) = source_local_event_id { + context = context.with_source_local_event_id(source_local_event_id.to_owned()); + } + let payload = AppPublishPayload::Listing(AppListingPublishPayload { + context, + product_id, + listing_d_tag: Some(listing_d_tag), + farm_id: Some(farm_id), + farm_pubkey: Some(farm_pubkey), + farm_d_tag: Some(d_tag_from_uuid(farm_id.as_uuid())), + title: draft.title.trim().to_owned(), + subtitle: non_empty_string(draft.subtitle.as_str()), + category: non_empty_string(draft.category.as_str()), + unit_label: draft.unit_label.trim().to_owned(), + price_minor_units: draft.price_minor_units, + price_currency: draft.price_currency.trim().to_uppercase(), + stock_quantity: draft.stock_quantity, + availability_window_id: draft.availability_window_id, + fulfillment_method: listing_fulfillment_method( + &draft, + farm_setup, + self.state_store.farm_rules_projection(), + ), + fulfillment_location: listing_fulfillment_location( + &draft, + farm_setup, + self.state_store.farm_rules_projection(), + ), + status: draft.status, + }); + if payload.validate().is_err() { + return Ok(None); + } + + PendingSyncOperation::from_publish_payload(payload, current_utc_timestamp()) + .map(Some) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "product publish payload must serialize", + }) + } + fn selected_account_id(&self) -> Result<String, DesktopAppRuntimeFarmSetupError> { self.selected_account_for_farm_setup() .map(|account| account.account.account_id.clone()) @@ -3806,9 +3880,9 @@ impl DesktopAppRuntimeState { &self, product_id: ProductId, draft: &ProductEditorDraft, - ) -> Result<bool, AppSqliteError> { + ) -> Result<Option<String>, AppSqliteError> { let Some(shared_accounts_paths) = self.shared_accounts_paths.as_ref() else { - return Ok(false); + return Ok(None); }; let Some(account) = self .state_store @@ -3816,48 +3890,40 @@ impl DesktopAppRuntimeState { .selected_account .as_ref() else { - return Ok(false); + return Ok(None); }; let Some(farm_id) = self.selected_farm_id() else { - return Ok(false); + return Ok(None); }; let timestamp = current_runtime_time_ms()?; let farm_d_tag = d_tag_from_uuid(farm_id.as_uuid()); let listing_d_tag = d_tag_from_uuid(product_id.as_uuid()); + let primary_bin_id = listing_primary_bin_id(listing_d_tag.as_str()); let owner_pubkey = self.local_events_owner_pubkey(account); let listing_addr = owner_pubkey .as_ref() .map(|pubkey| format!("30402:{pubkey}:{listing_d_tag}")); let exportability = local_work_exportability(owner_pubkey.as_deref()); let farm_setup = self.state_store.farm_setup_projection(); - let delivery_method = farm_setup - .draft - .order_methods - .iter() - .next() - .map(|method| method.storage_key()) - .unwrap_or("pickup"); - let location_primary = if farm_setup.draft.location_or_service_area.trim().is_empty() { - "local pickup" - } else { - farm_setup.draft.location_or_service_area.as_str() - }; - let unit_label = if draft.unit_label.trim().is_empty() { - "each" - } else { - draft.unit_label.as_str() - }; - let price_amount = draft - .price_minor_units - .map(decimal_from_minor_units) - .unwrap_or_else(|| "0".to_owned()); - let available = draft - .stock_quantity - .map(|value| value.to_string()) - .unwrap_or_else(|| "0".to_owned()); + let farm_rules = self.state_store.farm_rules_projection(); + let delivery_method = listing_fulfillment_method(draft, farm_setup, farm_rules); + let location_primary = listing_fulfillment_location(draft, farm_setup, farm_rules); + let category = non_empty_string(draft.category.as_str()); + let unit_label = non_empty_string(draft.unit_label.as_str()); + let price_amount = draft.price_minor_units.map(decimal_from_minor_units); + let available = draft.stock_quantity.map(|value| value.to_string()); + let publish_blockers = + derive_product_publish_blockers(draft, self.state_store.farm_readiness_projection()) + .into_iter() + .map(|blocker| blocker.storage_key()) + .collect::<Vec<_>>(); let payload = json!({ "record_kind": "listing_draft_v1", "exportability": exportability, + "publishability": { + "state": if publish_blockers.is_empty() { "publishable" } else { "blocked" }, + "blockers": publish_blockers, + }, "document": { "version": 1, "kind": "listing_draft_v1", @@ -3873,11 +3939,11 @@ impl DesktopAppRuntimeState { "product": { "key": listing_d_tag, "title": draft.title, - "category": "produce", + "category": category, "summary": draft.subtitle, }, "primary_bin": { - "bin_id": "bin-1", + "bin_id": primary_bin_id, "quantity_amount": "1", "quantity_unit": unit_label, "price_amount": price_amount, @@ -3900,8 +3966,9 @@ impl DesktopAppRuntimeState { }, }, }); + let record_id = format!("app:local_work:listing:{listing_d_tag}:{}", Uuid::now_v7()); let input = LocalEventRecordInput { - record_id: format!("app:local_work:listing:{listing_d_tag}:{}", Uuid::now_v7()), + record_id: record_id.clone(), family: LocalRecordFamily::LocalWork, status: LocalRecordStatus::LocalSaved, source_runtime: SourceRuntime::App, @@ -3926,7 +3993,7 @@ impl DesktopAppRuntimeState { }; self.append_app_local_work_record(shared_accounts_paths, &input)?; - Ok(true) + Ok(Some(record_id)) } fn append_app_buyer_order_request_local_work_record( @@ -4531,6 +4598,67 @@ fn normalized_app_sync_relay_urls( Ok(normalized) } +fn non_empty_string(value: &str) -> Option<String> { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +fn product_status_needs_relay_publish(status: ProductStatus) -> bool { + !matches!(status, ProductStatus::Draft) +} + +fn listing_primary_bin_id(listing_d_tag: &str) -> String { + format!("{listing_d_tag}:primary") +} + +fn listing_fulfillment_method( + draft: &ProductEditorDraft, + farm_setup: &FarmSetupProjection, + farm_rules: &FarmRulesProjection, +) -> Option<String> { + if draft.availability_window_id.is_some_and(|window_id| { + farm_rules + .fulfillment_windows + .iter() + .any(|window| window.fulfillment_window_id == window_id) + }) { + return Some(FarmOrderMethod::Pickup.storage_key().to_owned()); + } + + farm_setup + .draft + .order_methods + .iter() + .next() + .map(|method| method.storage_key().to_owned()) +} + +fn listing_fulfillment_location( + draft: &ProductEditorDraft, + farm_setup: &FarmSetupProjection, + farm_rules: &FarmRulesProjection, +) -> Option<String> { + draft + .availability_window_id + .and_then(|window_id| { + farm_rules + .fulfillment_windows + .iter() + .find(|window| window.fulfillment_window_id == window_id) + }) + .and_then(|window| { + farm_rules + .pickup_locations + .iter() + .find(|location| location.pickup_location_id == window.pickup_location_id) + }) + .and_then(|location| { + non_empty_string(location.address_line.as_str()) + .or_else(|| non_empty_string(location.label.as_str())) + }) + .or_else(|| non_empty_string(farm_setup.draft.location_or_service_area.as_str())) +} + fn direct_relay_sdk_client( relay_urls: Vec<String>, timeout_ms: u64, @@ -4624,15 +4752,16 @@ fn listing_publish_payload_to_sdk_listing( .ok_or_else(|| AppSyncTransportError::failed("publishable listing requires farm pubkey"))? .trim() .to_owned(); - let bin_id = "default".to_owned(); + let d_tag = payload + .listing_d_tag + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| d_tag_from_uuid(payload.product_id.as_uuid())); + let bin_id = listing_primary_bin_id(d_tag.as_str()); Ok(RadrootsListing { - d_tag: payload - .listing_d_tag - .as_deref() - .filter(|value| !value.trim().is_empty()) - .map(str::to_owned) - .unwrap_or_else(|| d_tag_from_uuid(payload.product_id.as_uuid())), + d_tag, farm: RadrootsFarmRef { pubkey: farm_pubkey, d_tag: payload @@ -6440,33 +6569,6 @@ fn fulfillment_window_sync_payload( .to_string() } -fn product_sync_payload( - product_id: ProductId, - farm_id: Option<FarmId>, - source: &str, - draft: Option<&ProductEditorDraft>, - stock_quantity: Option<u32>, - status: Option<&str>, -) -> String { - json!({ - "aggregate_kind": "product", - "product_id": product_id.to_string(), - "farm_id": farm_id.map(|value| value.to_string()), - "title": draft.map(|value| value.title.clone()), - "subtitle": draft.map(|value| value.subtitle.clone()), - "unit_label": draft.map(|value| value.unit_label.clone()), - "price_minor_units": draft.and_then(|value| value.price_minor_units), - "price_currency": draft.map(|value| value.price_currency.clone()), - "stock_quantity": stock_quantity.or_else(|| draft.and_then(|value| value.stock_quantity)), - "availability_window_id": draft - .and_then(|value| value.availability_window_id) - .map(|value| value.to_string()), - "status": status.or_else(|| draft.map(|value| value.status.storage_key())), - "source": source, - }) - .to_string() -} - fn order_sync_payload( order_id: OrderId, farm_id: FarmId, @@ -6506,18 +6608,18 @@ mod tests { AppActivityKind, AppIdentityProjection, AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, BuyerCheckoutDraft, BuyerOrderStatus, FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness, - FarmReadinessBlocker, FarmSetupDraft, FarmSetupProjection, FarmSummary, - FarmerActivationProjection, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, - LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, PackDayBatchPrintArtifact, - PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, PackDayExportInstanceId, - PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow, - PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, PackDayProductTotalRow, - PackDayProjection, PackDayRosterRow, PersonalSection, PickupLocationId, - PickupLocationRecord, ProductEditorDraft, ProductId, ProductStatus, ProductsFilter, - ProductsSort, RecoveryKind, RecoveryRecordId, ReminderDeliveryState, - ReminderFeedProjection, ReminderKind, SelectedAccountProjection, SelectedSurfaceProjection, - SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, - TodaySetupTaskKind, TodaySummary, + FarmReadinessBlocker, FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, + FarmSummary, FarmerActivationProjection, FarmerSection, FulfillmentWindowId, + FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, + PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, + PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, + PackDayHostHandoffStatus, PackDayPackListRow, PackDayPrintFailureKind, PackDayPrintKind, + PackDayPrintStatus, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, + PersonalSection, PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductId, + ProductStatus, ProductsFilter, ProductsSort, RecoveryKind, RecoveryRecordId, + ReminderDeliveryState, ReminderFeedProjection, ReminderKind, SelectedAccountProjection, + SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, + TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, @@ -6560,7 +6662,8 @@ mod tests { APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeActivityContextError, DesktopAppRuntimeCommandError, DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeState, DesktopAppSyncStatusSummary, DesktopRemoteSignerPaths, SYNC_TRANSPORT_UNAVAILABLE_MESSAGE, - SdkDirectRelayAppSyncTransport, TokioRuntimeBuilder, default_sync_transport, is_hex_64, + SdkDirectRelayAppSyncTransport, TokioRuntimeBuilder, default_sync_transport, + farm_sync_payload, is_hex_64, pending_sync_upsert, }; use crate::pack_day_host_handoff::PackDayHostHandoffError; use crate::pack_day_print::{ @@ -6999,9 +7102,9 @@ mod tests { } #[test] - fn runtime_outbox_repeated_product_save_deduplicates_active_pending_sync() { + fn runtime_product_incomplete_save_does_not_enqueue_publish_work() { let runtime = memory_runtime(); - let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + let (account_id, _) = provision_ready_farmer_account(&runtime); assert!( runtime @@ -7019,6 +7122,7 @@ mod tests { let first_draft = ProductEditorDraft { title: "Salad mix".to_owned(), subtitle: "Spring blend".to_owned(), + category: String::new(), unit_label: "bag".to_owned(), price_minor_units: Some(700), price_currency: "USD".to_owned(), @@ -7029,6 +7133,7 @@ mod tests { let second_draft = ProductEditorDraft { title: "Winter greens".to_owned(), subtitle: "Cut this morning".to_owned(), + category: "greens".to_owned(), unit_label: "bag".to_owned(), price_minor_units: Some(900), price_currency: "USD".to_owned(), @@ -7055,34 +7160,181 @@ mod tests { .expect("sqlite store") .load_pending_sync_operations(account_id.as_str()) .expect("pending sync operations should load"); - let expected_payload = super::product_sync_payload( - product_id, - Some(farm_id), - "save_product_editor_draft", - Some(&second_draft), - second_draft.stock_quantity, - None, + + assert_eq!(pending_operations.len(), 0); + assert_eq!( + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_product_editor_draft(product_id) + .expect("saved product draft should load"), + Some(second_draft) ); + } - assert_eq!(pending_operations.len(), 1); + #[test] + fn runtime_product_publishable_save_enqueues_typed_listing_publish_work() { + let (runtime, paths) = bootstrapped_runtime("publishable_product_listing_work"); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + let pickup_location_id = PickupLocationId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + + runtime + .save_farm_rules_projection(FarmRulesProjection { + farm_profile: Some(FarmProfileRecord { + farm_id, + display_name: "North field farm".to_owned(), + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + }), + pickup_locations: vec![PickupLocationRecord { + pickup_location_id, + farm_id, + label: "Barn pickup".to_owned(), + address_line: "14 Orchard Lane".to_owned(), + directions: None, + is_default: true, + }], + operating_rules: Some(FarmOperatingRulesRecord { + farm_id, + promise_lead_hours: 24, + substitution_policy: "ask_customer".to_owned(), + missed_pickup_policy: "hold_next_window".to_owned(), + }), + fulfillment_windows: vec![FulfillmentWindowRecord { + fulfillment_window_id, + farm_id, + pickup_location_id, + label: "Friday pickup".to_owned(), + starts_at: "2099-04-25T14:00:00Z".to_owned(), + ends_at: "2099-04-25T18:00:00Z".to_owned(), + order_cutoff_at: "2099-04-24T18:00:00Z".to_owned(), + }], + blackout_periods: Vec::new(), + ..runtime + .load_farm_rules_projection() + .expect("farm rules projection should load") + }) + .expect("farm rules should save"); + + assert!( + runtime + .open_new_product_editor() + .expect("new product editor should open") + ); + let product_id = match runtime.summary().products_projection.editor { + radroots_app_state::ProductEditorState::Open(session) => session + .selected_product_id + .expect("open product editor should select a product"), + radroots_app_state::ProductEditorState::Closed => { + panic!("product editor should be open") + } + }; + + assert!( + runtime + .save_product_editor_draft(ProductEditorDraft { + title: "Salad mix".to_owned(), + subtitle: "Cut this morning".to_owned(), + category: "greens".to_owned(), + unit_label: "bag".to_owned(), + price_minor_units: Some(900), + price_currency: "usd".to_owned(), + stock_quantity: Some(11), + availability_window_id: Some(fulfillment_window_id), + status: ProductStatus::Published, + }) + .expect("publishable product save should succeed") + ); + + let pending_operations = runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_pending_sync_operations(account_id.as_str()) + .expect("pending sync operations should load"); + let product_pending_operations = pending_operations + .iter() + .filter(|pending| pending.operation.aggregate == SyncAggregateRef::Product(product_id)) + .collect::<Vec<_>>(); + assert_eq!(product_pending_operations.len(), 1); assert_eq!( - pending_operations[0].operation.operation_key, + product_pending_operations[0].operation.operation_key, format!("product:{product_id}:upsert") ); assert_eq!( - pending_operations[0].operation.payload_json, - expected_payload + product_pending_operations[0].operation.state, + PendingSyncOperationState::Pending ); + let publish_payload = product_pending_operations[0] + .operation + .publish_payload() + .expect("product publish operation should be typed"); + let AppPublishPayload::Listing(payload) = publish_payload else { + panic!("product publish operation should carry listing payload") + }; + assert_eq!(payload.product_id, product_id); + assert_eq!(payload.category.as_deref(), Some("greens")); + assert_eq!(payload.unit_label, "bag"); + assert_eq!(payload.price_minor_units, Some(900)); + assert_eq!(payload.price_currency, "USD"); + assert_eq!(payload.stock_quantity, Some(11)); + assert_eq!(payload.fulfillment_method.as_deref(), Some("pickup")); assert_eq!( - pending_operations[0].operation.state, - PendingSyncOperationState::Pending + payload.fulfillment_location.as_deref(), + Some("14 Orchard Lane") + ); + assert!(payload.farm_pubkey.as_deref().is_some_and(super::is_hex_64)); + assert!( + payload + .context + .source_local_event_id + .as_deref() + .is_some_and(|value| value.starts_with("app:local_work:listing:")) + ); + + let records = shared_local_event_records(&paths); + let listing_record = records + .iter() + .find(|record| { + record + .local_work_json + .as_ref() + .and_then(|payload| payload["record_kind"].as_str()) + == Some("listing_draft_v1") + }) + .expect("listing local work record"); + let listing_payload = listing_record + .local_work_json + .as_ref() + .expect("listing local work payload"); + assert_eq!(listing_payload["publishability"]["state"], "publishable"); + assert_eq!(listing_payload["document"]["product"]["category"], "greens"); + assert_eq!( + listing_payload["document"]["primary_bin"]["bin_id"] + .as_str() + .expect("primary bin id should be present"), + super::listing_primary_bin_id( + payload + .listing_d_tag + .as_deref() + .expect("listing d tag should exist") + ) ); - assert_eq!(pending_operations[0].operation.attempt_count, 0); - assert_eq!(pending_operations[0].operation.last_error_message, None); + assert_eq!(listing_payload["document"]["delivery"]["method"], "pickup"); + assert_eq!( + listing_payload["document"]["location"]["primary"], + "14 Orchard Lane" + ); + + cleanup_bootstrapped_runtime_paths(&paths); } #[test] - fn runtime_local_product_mutations_enqueue_pending_sync_without_transport_calls() { + fn runtime_product_local_drafts_do_not_enqueue_publish_work_without_required_fields() { let runtime = memory_runtime(); let (account_id, _) = provision_ready_farmer_account(&runtime); let recorded = install_recorded_sync_transport( @@ -7117,24 +7369,26 @@ mod tests { .expect("pending sync operations should load"); assert_eq!(recorded.lock().expect("recorded transport").call_count(), 0); - assert_eq!(summary.sync_status.pending_write_count, 1); - assert_eq!(pending_operations.len(), 1); - assert!(matches!( - pending_operations[0].operation.aggregate, - SyncAggregateRef::Product(_) - )); + assert_eq!(summary.sync_status.pending_write_count, 0); + assert_eq!(pending_operations.len(), 0); } #[test] fn runtime_launch_sync_attempt_dequeues_pushed_operations() { let runtime = memory_runtime(); - let (account_id, _) = provision_ready_farmer_account(&runtime); - - assert!( - runtime - .open_new_product_editor() - .expect("new product editor should open") - ); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + runtime + .lock_state_mut() + .enqueue_selected_account_sync_operations(vec![pending_sync_upsert( + SyncAggregateRef::Farm(farm_id), + farm_sync_payload( + farm_id, + "North field farm", + Some(FarmReadiness::Ready), + "launch_sync_attempt_dequeues_pushed_operations", + ), + )]) + .expect("pending farm sync should enqueue"); let recorded = install_recorded_sync_transport( &runtime, @@ -7603,6 +7857,7 @@ mod tests { .save_product_editor_draft(ProductEditorDraft { title: "Eggs".to_owned(), subtitle: "Fresh eggs".to_owned(), + category: "eggs".to_owned(), unit_label: "dozen".to_owned(), price_minor_units: Some(750), price_currency: "USD".to_owned(), @@ -7695,17 +7950,29 @@ mod tests { .as_ref() .expect("listing local work payload"); assert_eq!(listing_payload["exportability"]["state"], "exportable"); + assert_eq!(listing_payload["publishability"]["state"], "blocked"); assert_eq!(listing_payload["document"]["kind"], "listing_draft_v1"); assert_eq!( listing_payload["document"]["seller_actor"]["pubkey"], owner_pubkey ); assert_eq!(listing_payload["document"]["product"]["title"], "Eggs"); + assert_eq!(listing_payload["document"]["product"]["category"], "eggs"); + assert!( + listing_payload["document"]["primary_bin"]["bin_id"] + .as_str() + .is_some_and(|value| value.ends_with(":primary")) + ); assert_eq!( listing_payload["document"]["primary_bin"]["price_amount"], "7.50" ); assert_eq!(listing_payload["document"]["inventory"]["available"], "12"); + assert_eq!(listing_payload["document"]["delivery"]["method"], "pickup"); + assert_eq!( + listing_payload["document"]["location"]["primary"], + "farmstand" + ); assert!(listing_payload.get("draft").is_none()); assert!(listing_payload.get("editor").is_none()); @@ -7749,6 +8016,7 @@ mod tests { &ProductEditorDraft { title: "Eggs".to_owned(), subtitle: "Fresh eggs".to_owned(), + category: "eggs".to_owned(), unit_label: "dozen".to_owned(), price_minor_units: Some(750), price_currency: "USD".to_owned(), @@ -7811,13 +8079,19 @@ mod tests { #[test] fn runtime_manual_refresh_marks_failed_checkpoint_when_transport_is_unavailable() { let runtime = memory_runtime(); - let (account_id, _) = provision_ready_farmer_account(&runtime); - - assert!( - runtime - .open_new_product_editor() - .expect("new product editor should open") - ); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + runtime + .lock_state_mut() + .enqueue_selected_account_sync_operations(vec![pending_sync_upsert( + SyncAggregateRef::Farm(farm_id), + farm_sync_payload( + farm_id, + "North field farm", + Some(FarmReadiness::Ready), + "manual_refresh_unavailable_transport", + ), + )]) + .expect("pending farm sync should enqueue"); assert!( runtime @@ -7860,12 +8134,18 @@ mod tests { fn runtime_sync_attempts_stop_when_blocking_conflicts_are_present() { let runtime = memory_runtime(); let (account_id, farm_id) = provision_ready_farmer_account(&runtime); - - assert!( - runtime - .open_new_product_editor() - .expect("new product editor should open") - ); + runtime + .lock_state_mut() + .enqueue_selected_account_sync_operations(vec![pending_sync_upsert( + SyncAggregateRef::Farm(farm_id), + farm_sync_payload( + farm_id, + "North field farm", + Some(FarmReadiness::Ready), + "blocking_conflict_stops_sync", + ), + )]) + .expect("pending farm sync should enqueue"); runtime .lock_state() @@ -12206,6 +12486,7 @@ mod tests { let saved_draft = ProductEditorDraft { title: "Salad mix".to_owned(), subtitle: "Washed and boxed".to_owned(), + category: "greens".to_owned(), unit_label: "box".to_owned(), price_minor_units: Some(900), price_currency: "usd".to_owned(), diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -578,6 +578,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::ProductsEditorBody", "AppTextKey::ProductsEditorFieldTitle", "AppTextKey::ProductsEditorFieldSubtitle", + "AppTextKey::ProductsEditorFieldCategory", "AppTextKey::ProductsEditorFieldUnit", "AppTextKey::ProductsEditorFieldPrice", "AppTextKey::ProductsEditorFieldStock", @@ -590,8 +591,10 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::ProductsEditorPublishReadinessTitle", "AppTextKey::ProductsEditorReady", "AppTextKey::ProductsEditorBlockerAddProductName", + "AppTextKey::ProductsEditorBlockerChooseCategory", "AppTextKey::ProductsEditorBlockerChooseUnit", "AppTextKey::ProductsEditorBlockerSetPrice", + "AppTextKey::ProductsEditorBlockerSetStock", "AppTextKey::ProductsEditorBlockerAttachAvailability", "AppTextKey::ProductsUntitledDraft", "AppTextKey::ProductsStockEditorTitle", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -4862,11 +4862,13 @@ struct ProductEditorFormState { status: ProductStatus, title_input: Entity<InputState>, subtitle_input: Entity<InputState>, + category_input: Entity<InputState>, unit_input: Entity<InputState>, price_input: Entity<InputState>, stock_input: Entity<InputState>, _title_subscription: Subscription, _subtitle_subscription: Subscription, + _category_subscription: Subscription, _unit_subscription: Subscription, _price_subscription: Subscription, _stock_subscription: Subscription, @@ -4885,6 +4887,8 @@ impl ProductEditorFormState { cx.new(|cx| InputState::new(window, cx).default_value(draft.title.clone())); let subtitle_input = cx.new(|cx| InputState::new(window, cx).default_value(draft.subtitle.clone())); + let category_input = + cx.new(|cx| InputState::new(window, cx).default_value(draft.category.clone())); let unit_input = cx.new(|cx| InputState::new(window, cx).default_value(draft.unit_label.clone())); let price_input = cx.new(|cx| { @@ -4909,6 +4913,11 @@ impl ProductEditorFormState { window, HomeView::handle_product_editor_input_event, ); + let category_subscription = cx.subscribe_in( + &category_input, + window, + HomeView::handle_product_editor_input_event, + ); let unit_subscription = cx.subscribe_in( &unit_input, window, @@ -4932,11 +4941,13 @@ impl ProductEditorFormState { initial_draft: draft, title_input, subtitle_input, + category_input, unit_input, price_input, stock_input, _title_subscription: title_subscription, _subtitle_subscription: subtitle_subscription, + _category_subscription: category_subscription, _unit_subscription: unit_subscription, _price_subscription: price_subscription, _stock_subscription: stock_subscription, @@ -4948,6 +4959,7 @@ impl ProductEditorFormState { Some(ProductEditorDraft { title: self.title_input.read(cx).value().to_string(), subtitle: self.subtitle_input.read(cx).value().to_string(), + category: self.category_input.read(cx).value().to_string(), unit_label: self.unit_input.read(cx).value().to_string(), price_minor_units: parse_product_editor_price_input( self.price_input.read(cx).value().as_ref(), @@ -11622,6 +11634,14 @@ fn products_editor_surface( )) .child(app_form_input_text( AppFormFieldSpec::new( + app_shared_text(AppTextKey::ProductsEditorFieldCategory), + Option::<SharedString>::None, + ), + &form.category_input, + false, + )) + .child(app_form_input_text( + AppFormFieldSpec::new( app_shared_text(AppTextKey::ProductsEditorFieldUnit), Option::<SharedString>::None, ), @@ -11819,8 +11839,10 @@ fn products_editor_publish_blocker_row(blocker: ProductPublishBlocker) -> AnyEle fn products_editor_publish_blocker_key(blocker: ProductPublishBlocker) -> AppTextKey { match blocker { ProductPublishBlocker::AddProductName => AppTextKey::ProductsEditorBlockerAddProductName, + ProductPublishBlocker::ChooseCategory => AppTextKey::ProductsEditorBlockerChooseCategory, ProductPublishBlocker::ChooseUnit => AppTextKey::ProductsEditorBlockerChooseUnit, ProductPublishBlocker::SetPrice => AppTextKey::ProductsEditorBlockerSetPrice, + ProductPublishBlocker::SetStock => AppTextKey::ProductsEditorBlockerSetStock, ProductPublishBlocker::AttachAvailability => { AppTextKey::ProductsEditorBlockerAttachAvailability } diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -340,6 +340,7 @@ define_app_text_keys! { ProductsEditorBody => "products.editor.body", ProductsEditorFieldTitle => "products.editor.field.title", ProductsEditorFieldSubtitle => "products.editor.field.subtitle", + ProductsEditorFieldCategory => "products.editor.field.category", ProductsEditorFieldUnit => "products.editor.field.unit", ProductsEditorFieldPrice => "products.editor.field.price", ProductsEditorFieldStock => "products.editor.field.stock", @@ -352,8 +353,10 @@ define_app_text_keys! { ProductsEditorPublishReadinessTitle => "products.editor.publish_readiness.title", ProductsEditorReady => "products.editor.publish_readiness.ready", ProductsEditorBlockerAddProductName => "products.editor.blocker.add_product_name", + ProductsEditorBlockerChooseCategory => "products.editor.blocker.choose_category", ProductsEditorBlockerChooseUnit => "products.editor.blocker.choose_unit", ProductsEditorBlockerSetPrice => "products.editor.blocker.set_price", + ProductsEditorBlockerSetStock => "products.editor.blocker.set_stock", ProductsEditorBlockerAttachAvailability => "products.editor.blocker.attach_availability", ProductsEditorBlockerCompleteFarmProfile => "products.editor.blocker.complete_farm_profile", ProductsEditorBlockerAddPickupLocation => "products.editor.blocker.add_pickup_location", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -946,6 +946,10 @@ mod tests { ); assert_eq!(app_text(AppTextKey::ProductsEditorFieldTitle), "Name"); assert_eq!(app_text(AppTextKey::ProductsEditorFieldSubtitle), "Details"); + assert_eq!( + app_text(AppTextKey::ProductsEditorFieldCategory), + "Category" + ); assert_eq!(app_text(AppTextKey::ProductsEditorFieldUnit), "Unit"); assert_eq!( app_text(AppTextKey::ProductsEditorFieldPrice), @@ -971,6 +975,10 @@ mod tests { "Add a product name." ); assert_eq!( + app_text(AppTextKey::ProductsEditorBlockerChooseCategory), + "Choose a category." + ); + assert_eq!( app_text(AppTextKey::ProductsEditorBlockerChooseUnit), "Choose a unit." ); @@ -979,6 +987,10 @@ mod tests { "Set a price." ); assert_eq!( + app_text(AppTextKey::ProductsEditorBlockerSetStock), + "Set available stock." + ); + assert_eq!( app_text(AppTextKey::ProductsEditorBlockerAttachAvailability), "Attach an availability window." ); diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -1168,8 +1168,10 @@ impl ProductsListProjection { #[serde(rename_all = "snake_case")] pub enum ProductPublishBlocker { AddProductName, + ChooseCategory, ChooseUnit, SetPrice, + SetStock, AttachAvailability, CompleteFarmProfile, AddPickupLocation, @@ -1182,8 +1184,10 @@ impl ProductPublishBlocker { pub const fn storage_key(self) -> &'static str { match self { Self::AddProductName => "add_product_name", + Self::ChooseCategory => "choose_category", Self::ChooseUnit => "choose_unit", Self::SetPrice => "set_price", + Self::SetStock => "set_stock", Self::AttachAvailability => "attach_availability", Self::CompleteFarmProfile => "complete_farm_profile", Self::AddPickupLocation => "add_pickup_location", @@ -1198,6 +1202,7 @@ impl ProductPublishBlocker { pub struct ProductEditorDraft { pub title: String, pub subtitle: String, + pub category: String, pub unit_label: String, pub price_minor_units: Option<u32>, pub price_currency: String, @@ -1211,6 +1216,7 @@ impl Default for ProductEditorDraft { Self { title: String::new(), subtitle: String::new(), + category: String::new(), unit_label: String::new(), price_minor_units: None, price_currency: "USD".to_owned(), @@ -1229,6 +1235,10 @@ impl ProductEditorDraft { blockers.push(ProductPublishBlocker::AddProductName); } + if self.category.trim().is_empty() { + blockers.push(ProductPublishBlocker::ChooseCategory); + } + if self.unit_label.trim().is_empty() { blockers.push(ProductPublishBlocker::ChooseUnit); } @@ -1237,6 +1247,10 @@ impl ProductEditorDraft { blockers.push(ProductPublishBlocker::SetPrice); } + if self.stock_quantity.is_none() { + blockers.push(ProductPublishBlocker::SetStock); + } + if self.availability_window_id.is_none() { blockers.push(ProductPublishBlocker::AttachAvailability); } @@ -3084,6 +3098,7 @@ mod tests { let ready_draft = ProductEditorDraft { title: "Heirloom tomatoes".to_owned(), subtitle: "Brandywine".to_owned(), + category: "vegetables".to_owned(), unit_label: "lb".to_owned(), price_minor_units: Some(450), price_currency: "USD".to_owned(), @@ -3096,8 +3111,10 @@ mod tests { empty_draft.publish_blockers(), vec![ ProductPublishBlocker::AddProductName, + ProductPublishBlocker::ChooseCategory, ProductPublishBlocker::ChooseUnit, ProductPublishBlocker::SetPrice, + ProductPublishBlocker::SetStock, ProductPublishBlocker::AttachAvailability, ] ); diff --git a/crates/shared/sqlite/migrations/0017_product_category.sql b/crates/shared/sqlite/migrations/0017_product_category.sql @@ -0,0 +1 @@ +ALTER TABLE products ADD COLUMN category TEXT NOT NULL DEFAULT ''; diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -813,6 +813,7 @@ mod tests { )); assert!(column_exists(connection, "order_lines", "price_currency")); assert!(column_exists(connection, "order_lines", "listing_addr")); + assert!(column_exists(connection, "products", "category")); assert!(column_exists(connection, "products", "listing_bin_id")); assert!(column_exists(connection, "buyer_carts", "buyer_email")); assert!(column_exists(connection, "buyer_carts", "buyer_phone")); diff --git a/crates/shared/sqlite/src/migrations.rs b/crates/shared/sqlite/src/migrations.rs @@ -68,6 +68,10 @@ const MIGRATIONS: &[Migration] = &[ version: 16, sql: include_str!("../migrations/0016_deterministic_outbox.sql"), }, + Migration { + version: 17, + sql: include_str!("../migrations/0017_product_category.sql"), + }, ]; pub fn latest_schema_version() -> u32 { diff --git a/crates/shared/sqlite/src/products.rs b/crates/shared/sqlite/src/products.rs @@ -67,6 +67,7 @@ impl<'a> AppProductsRepository<'a> { farm_id, title, subtitle, + category, status, unit_label, price_minor_units, @@ -79,6 +80,7 @@ impl<'a> AppProductsRepository<'a> { ?2, '', '', + '', 'draft', '', null, @@ -109,18 +111,20 @@ impl<'a> AppProductsRepository<'a> { set title = ?2, subtitle = ?3, - status = ?4, - unit_label = ?5, - price_minor_units = ?6, - price_currency = ?7, - stock_count = ?8, - availability_window_id = ?9, + category = ?4, + status = ?5, + unit_label = ?6, + price_minor_units = ?7, + price_currency = ?8, + stock_count = ?9, + availability_window_id = ?10, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') where id = ?1", params![ product_id.to_string(), draft.title.as_str(), draft.subtitle.as_str(), + draft.category.as_str(), draft.status.storage_key(), draft.unit_label.as_str(), draft.price_minor_units, @@ -189,6 +193,7 @@ impl<'a> AppProductsRepository<'a> { p.farm_id, p.title, p.subtitle, + p.category, p.status, p.unit_label, p.price_minor_units, @@ -215,13 +220,14 @@ impl<'a> AppProductsRepository<'a> { row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?, - row.get::<_, Option<u32>>(6)?, - row.get::<_, String>(7)?, - row.get::<_, Option<u32>>(8)?, - row.get::<_, Option<String>>(9)?, + row.get::<_, String>(6)?, + row.get::<_, Option<u32>>(7)?, + row.get::<_, String>(8)?, + row.get::<_, Option<u32>>(9)?, row.get::<_, Option<String>>(10)?, row.get::<_, Option<String>>(11)?, - row.get::<_, String>(12)?, + row.get::<_, Option<String>>(12)?, + row.get::<_, String>(13)?, )) }) .map_err(|source| AppSqliteError::Query { @@ -236,6 +242,7 @@ impl<'a> AppProductsRepository<'a> { farm_id, title, subtitle, + category, status, unit_label, price_minor_units, @@ -255,6 +262,7 @@ impl<'a> AppProductsRepository<'a> { farm_id: parse_typed_id("products.farm_id", farm_id)?, title, subtitle, + category, status: parse_product_status("products.status", status)?, unit_label, price_minor_units, @@ -285,6 +293,7 @@ impl<'a> AppProductsRepository<'a> { p.farm_id, p.title, p.subtitle, + p.category, p.status, p.unit_label, p.price_minor_units, @@ -307,13 +316,14 @@ impl<'a> AppProductsRepository<'a> { row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?, - row.get::<_, Option<u32>>(6)?, - row.get::<_, String>(7)?, - row.get::<_, Option<u32>>(8)?, - row.get::<_, Option<String>>(9)?, + row.get::<_, String>(6)?, + row.get::<_, Option<u32>>(7)?, + row.get::<_, String>(8)?, + row.get::<_, Option<u32>>(9)?, row.get::<_, Option<String>>(10)?, row.get::<_, Option<String>>(11)?, - row.get::<_, String>(12)?, + row.get::<_, Option<String>>(12)?, + row.get::<_, String>(13)?, )) }, ) @@ -329,6 +339,7 @@ impl<'a> AppProductsRepository<'a> { farm_id, title, subtitle, + category, status, unit_label, price_minor_units, @@ -344,6 +355,7 @@ impl<'a> AppProductsRepository<'a> { farm_id: parse_typed_id("products.farm_id", farm_id)?, title, subtitle, + category, status: parse_product_status("products.status", status)?, unit_label, price_minor_units, @@ -369,6 +381,7 @@ struct ProductRecord { farm_id: FarmId, title: String, subtitle: String, + category: String, status: ProductStatus, unit_label: String, price_minor_units: Option<u32>, @@ -405,6 +418,7 @@ impl ProductRecord { ProductEditorDraft { title: self.title, subtitle: self.subtitle, + category: self.category, unit_label: self.unit_label, price_minor_units: self.price_minor_units, price_currency: self.price_currency, @@ -737,6 +751,7 @@ mod tests { SeedProduct { title: "Salad mix", subtitle: "Spring blend", + category: "greens", status: "published", unit_label: "box", price_minor_units: Some(600), @@ -751,6 +766,7 @@ mod tests { SeedProduct { title: "Pea shoots", subtitle: "Tray-grown", + category: "", status: "draft", unit_label: "bag", price_minor_units: Some(300), @@ -765,6 +781,7 @@ mod tests { SeedProduct { title: "Heirloom tomatoes", subtitle: "Brandywine", + category: "vegetables", status: "published", unit_label: "lb", price_minor_units: Some(450), @@ -779,10 +796,11 @@ mod tests { SeedProduct { title: "Carrot bunches", subtitle: "Nantes", + category: "vegetables", status: "paused", unit_label: "each", price_minor_units: Some(400), - stock_count: None, + stock_count: Some(7), availability_window_id: None, updated_at: "2026-04-18T07:00:00Z", }, @@ -793,6 +811,7 @@ mod tests { SeedProduct { title: "Old beets", subtitle: "", + category: "vegetables", status: "archived", unit_label: "bunch", price_minor_units: Some(250), @@ -869,6 +888,7 @@ mod tests { let saved_draft = ProductEditorDraft { title: "Heirloom tomatoes".to_owned(), subtitle: "Brandywine".to_owned(), + category: "vegetables".to_owned(), unit_label: "lb".to_owned(), price_minor_units: Some(450), price_currency: "usd".to_owned(), @@ -925,8 +945,10 @@ mod tests { .expect("blockers should load"), Some(vec![ ProductPublishBlocker::AddProductName, + ProductPublishBlocker::ChooseCategory, ProductPublishBlocker::ChooseUnit, ProductPublishBlocker::SetPrice, + ProductPublishBlocker::SetStock, ProductPublishBlocker::AttachAvailability, ]) ); @@ -938,10 +960,11 @@ mod tests { &ProductEditorDraft { title: "Salad mix".to_owned(), subtitle: "Spring blend".to_owned(), + category: "greens".to_owned(), unit_label: "box".to_owned(), price_minor_units: Some(600), price_currency: "USD".to_owned(), - stock_quantity: None, + stock_quantity: Some(12), availability_window_id: Some(window_id), status: ProductStatus::Published, }, @@ -1040,6 +1063,7 @@ mod tests { farm_id, title, subtitle, + category, status, unit_label, price_minor_units, @@ -1047,12 +1071,13 @@ mod tests { stock_count, availability_window_id, updated_at - ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'USD', ?8, ?9, ?10)", + ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'USD', ?9, ?10, ?11)", params![ product_id.to_string(), farm_id.to_string(), seed.title, seed.subtitle, + seed.category, seed.status, seed.unit_label, seed.price_minor_units, @@ -1069,6 +1094,7 @@ mod tests { struct SeedProduct<'a> { title: &'a str, subtitle: &'a str, + category: &'a str, status: &'a str, unit_label: &'a str, price_minor_units: Option<u32>, diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -3437,6 +3437,7 @@ mod tests { let ready_draft = ProductEditorDraft { title: "Heirloom tomatoes".to_owned(), subtitle: "Brandywine".to_owned(), + category: "vegetables".to_owned(), unit_label: "lb".to_owned(), price_minor_units: Some(450), price_currency: "USD".to_owned(), @@ -3456,8 +3457,10 @@ mod tests { draft: ProductEditorDraft::default(), publish_blockers: vec![ ProductPublishBlocker::AddProductName, + ProductPublishBlocker::ChooseCategory, ProductPublishBlocker::ChooseUnit, ProductPublishBlocker::SetPrice, + ProductPublishBlocker::SetStock, ProductPublishBlocker::AttachAvailability, ], }) diff --git a/crates/shared/sync/src/publish.rs b/crates/shared/sync/src/publish.rs @@ -195,6 +195,9 @@ impl AppPublishPayload { if payload.availability_window_id.is_none() { failures.push(AppPublishValidationFailure::MissingListingAvailability); } + if payload.stock_quantity.is_none() { + failures.push(AppPublishValidationFailure::MissingListingStock); + } if payload .fulfillment_method .as_deref() @@ -202,6 +205,13 @@ impl AppPublishPayload { { failures.push(AppPublishValidationFailure::MissingListingFulfillmentMethod); } + if payload + .fulfillment_location + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + failures.push(AppPublishValidationFailure::MissingListingFulfillmentLocation); + } } Self::OrderRequest(payload) => { payload.context.validation_failures(&mut failures); @@ -292,7 +302,9 @@ pub enum AppPublishValidationFailure { MissingListingPrice, MissingListingCurrency, MissingListingAvailability, + MissingListingStock, MissingListingFulfillmentMethod, + MissingListingFulfillmentLocation, MissingOrderDocument, MissingOrderListingAddress, MissingOrderListingEventId, @@ -318,7 +330,9 @@ impl AppPublishValidationFailure { Self::MissingListingPrice => "missing_listing_price", Self::MissingListingCurrency => "missing_listing_currency", Self::MissingListingAvailability => "missing_listing_availability", + Self::MissingListingStock => "missing_listing_stock", Self::MissingListingFulfillmentMethod => "missing_listing_fulfillment_method", + Self::MissingListingFulfillmentLocation => "missing_listing_fulfillment_location", Self::MissingOrderDocument => "missing_order_document", Self::MissingOrderListingAddress => "missing_order_listing_address", Self::MissingOrderListingEventId => "missing_order_listing_event_id", @@ -468,6 +482,7 @@ mod tests { "missing_listing_currency", "missing_listing_availability", "missing_listing_fulfillment_method", + "missing_listing_fulfillment_location", ] ); assert!(payload.validate().is_err()); diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -319,6 +319,7 @@ "products.editor.body": "Saved locally on this device.", "products.editor.field.title": "Name", "products.editor.field.subtitle": "Details", + "products.editor.field.category": "Category", "products.editor.field.unit": "Unit", "products.editor.field.price": "Price (USD)", "products.editor.field.stock": "Stock", @@ -331,8 +332,10 @@ "products.editor.publish_readiness.title": "Publish readiness", "products.editor.publish_readiness.ready": "This product is ready to publish.", "products.editor.blocker.add_product_name": "Add a product name.", + "products.editor.blocker.choose_category": "Choose a category.", "products.editor.blocker.choose_unit": "Choose a unit.", "products.editor.blocker.set_price": "Set a price.", + "products.editor.blocker.set_stock": "Set available stock.", "products.editor.blocker.attach_availability": "Attach an availability window.", "products.editor.blocker.complete_farm_profile": "Complete the farm profile in Settings before publishing.", "products.editor.blocker.add_pickup_location": "Add a pickup location in Settings before publishing.",