diff --git a/Cargo.lock b/Cargo.lock index 6ad21c4..2ac9037 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1604,6 +1604,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pawctioneer-bot" version = "0.1.0" @@ -1612,15 +1618,18 @@ dependencies = [ "async-trait", "chrono", "dotenvy", + "dptree", "env_logger", "futures", "itertools 0.14.0", "lazy_static", "log", "num", + "paste", "regex", "rstest", "rust_decimal", + "seq-macro", "serde", "sqlx", "teloxide", @@ -2248,6 +2257,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.219" diff --git a/Cargo.toml b/Cargo.toml index 1603f26..f950db1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,9 @@ num = "0.4.3" itertools = "0.14.0" async-trait = "0.1" regex = "1.11.2" +paste = "1.0" +dptree = "0.5.1" +seq-macro = "0.3.6" [dev-dependencies] rstest = "0.26.1" diff --git a/migrations/20240827001_initial_schema.sql b/migrations/20240827001_initial_schema.sql index 3a687fa..70e9a6f 100644 --- a/migrations/20240827001_initial_schema.sql +++ b/migrations/20240827001_initial_schema.sql @@ -23,6 +23,7 @@ CREATE TABLE listings ( listing_type TEXT NOT NULL, -- 'basic_auction', 'multi_slot_auction', 'fixed_price_listing', 'blind_auction' title TEXT NOT NULL, description TEXT, + currency_type TEXT NOT NULL, -- 'usd' -- Pricing (stored as INTEGER cents for USD) starting_bid INTEGER, diff --git a/src/commands/my_listings/keyboard.rs b/src/commands/my_listings/keyboard.rs index f3efd8e..696a160 100644 --- a/src/commands/my_listings/keyboard.rs +++ b/src/commands/my_listings/keyboard.rs @@ -16,22 +16,20 @@ use teloxide::types::InlineKeyboardButton; pub enum MyListingsButtons { SelectListing(ListingDbId), NewListing, - BackToMenu, } impl MyListingsButtons { pub fn listing_into_button(listing: &PersistedListing) -> InlineKeyboardButton { - InlineKeyboardButton::callback( - &listing.base.title, - Self::encode_listing_id(listing.persisted.id), - ) - } - - pub fn back_to_menu_into_button() -> InlineKeyboardButton { - InlineKeyboardButton::callback("Back to Menu", "my_listings_back_to_menu") + let text = format!( + "{} {} - {}", + listing.fields.listing_type().emoji_str(), + listing.fields.listing_type(), + listing.base.title, + ); + InlineKeyboardButton::callback(text, Self::encode_listing_id(listing.persisted.id)) } pub fn new_listing_into_button() -> InlineKeyboardButton { - InlineKeyboardButton::callback("πŸ›οΈ New Listing", "my_listings_new_listing") + InlineKeyboardButton::callback("βž• New Listing", "my_listings_new_listing") } fn encode_listing_id(listing_id: ListingDbId) -> String { @@ -54,7 +52,6 @@ impl TryFrom<&str> for MyListingsButtons { } match value { "my_listings_new_listing" => Ok(MyListingsButtons::NewListing), - "my_listings_back_to_menu" => Ok(MyListingsButtons::BackToMenu), _ => anyhow::bail!("Unknown MyListingsButtons: {value}"), } } diff --git a/src/commands/my_listings/mod.rs b/src/commands/my_listings/mod.rs index c46a213..8cbdbc3 100644 --- a/src/commands/my_listings/mod.rs +++ b/src/commands/my_listings/mod.rs @@ -5,15 +5,19 @@ use crate::{ commands::{ enter_main_menu, my_listings::keyboard::{ManageListingButtons, MyListingsButtons}, - new_listing::{enter_edit_listing_draft, enter_select_new_listing_type, ListingDraft}, + new_listing::{ + enter_edit_listing_draft, enter_select_new_listing_type, keyboard::NavKeyboardButtons, + ListingDraft, + }, }, db::{ listing::{ListingFields, PersistedListing}, user::PersistedUser, - ListingDAO, ListingDbId, + ListingDAO, ListingDbId, ListingType, }, handler_utils::{ - callback_query_into_message_target, find_or_create_db_user_from_callback_query, find_or_create_db_user_from_message, message_into_message_target + callback_query_into_message_target, find_or_create_db_user_from_callback_query, + find_or_create_db_user_from_message, message_into_message_target, }, message_utils::{extract_callback_data, pluralize_with_count, send_message, MessageTarget}, Command, DialogueRootState, HandlerResult, RootDialogue, @@ -146,7 +150,7 @@ async fn handle_forward_listing( .as_deref() .unwrap_or("No description"), current_price, - listing.persisted.end_at.format("%b %d, %Y at %H:%M UTC") + listing.base.ends_at.format("%b %d, %Y at %H:%M UTC") ); bot.answer_inline_query( @@ -221,7 +225,7 @@ pub async fn enter_my_listings( } keyboard = keyboard.append_row(vec![ MyListingsButtons::new_listing_into_button(), - MyListingsButtons::back_to_menu_into_button(), + NavKeyboardButtons::Back.to_button(), ]); if listings.is_empty() { @@ -261,9 +265,12 @@ async fn handle_viewing_listings_callback( ) -> HandlerResult { let data = extract_callback_data(&bot, callback_query).await?; + if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { + return enter_main_menu(bot, dialogue, target).await; + } + // Check if it's the back to menu button let button = MyListingsButtons::try_from(data.as_str())?; - match button { MyListingsButtons::SelectListing(listing_id) => { let listing = @@ -274,10 +281,6 @@ async fn handle_viewing_listings_callback( MyListingsButtons::NewListing => { enter_select_new_listing_type(bot, dialogue, target).await?; } - MyListingsButtons::BackToMenu => { - // Transition back to main menu using the reusable function - enter_main_menu(bot, dialogue, target).await? - } } Ok(()) @@ -289,8 +292,10 @@ async fn enter_show_listing_details( listing: PersistedListing, target: MessageTarget, ) -> HandlerResult { + let listing_type = Into::::into(&listing.fields); + let listing_id = listing.persisted.id; let response = format!( - "πŸ” Listing Details\n\n\ + "πŸ” {listing_type} Details\n\n\ Title: {}\n\ Description: {}\n", listing.base.title, @@ -301,29 +306,22 @@ async fn enter_show_listing_details( .unwrap_or("No description"), ); dialogue - .update(MyListingsState::ManagingListing(listing.persisted.id)) + .update(MyListingsState::ManagingListing(listing_id)) .await?; - send_message( - bot, - target, - response, - Some( - InlineKeyboardMarkup::default() - .append_row([ - ManageListingButtons::PreviewMessage.to_button(), - InlineKeyboardButton::switch_inline_query( - ManageListingButtons::ForwardListing.title(), - format!("forward_listing:{}", listing.persisted.id), - ), - ]) - .append_row([ - ManageListingButtons::Edit.to_button(), - ManageListingButtons::Delete.to_button(), - ]) - .append_row([ManageListingButtons::Back.to_button()]), - ), - ) - .await?; + let keyboard = InlineKeyboardMarkup::default() + .append_row([ + ManageListingButtons::PreviewMessage.to_button(), + InlineKeyboardButton::switch_inline_query( + ManageListingButtons::ForwardListing.title(), + format!("forward_listing:{listing_id}"), + ), + ]) + .append_row([ + ManageListingButtons::Edit.to_button(), + ManageListingButtons::Delete.to_button(), + ]) + .append_row([ManageListingButtons::Back.to_button()]); + send_message(bot, target, response, Some(keyboard)).await?; Ok(()) } @@ -357,7 +355,15 @@ async fn handle_managing_listing_callback( } ManageListingButtons::Delete => { ListingDAO::delete_listing(&db_pool, listing_id).await?; - send_message(&bot, target, "Listing deleted.", None).await?; + enter_my_listings( + db_pool, + bot, + dialogue, + user, + target, + Some("Listing deleted.".to_string()), + ) + .await?; } ManageListingButtons::Back => { enter_my_listings(db_pool, bot, dialogue, user, target, None).await?; diff --git a/src/commands/new_listing/callbacks.rs b/src/commands/new_listing/callbacks.rs index 3640b09..320c1e2 100644 --- a/src/commands/new_listing/callbacks.rs +++ b/src/commands/new_listing/callbacks.rs @@ -5,25 +5,27 @@ use crate::{ commands::{ + my_listings::enter_my_listings, new_listing::{ enter_select_new_listing_type, - field_processing::transition_to_field, + field_processing::{transition_to_field, update_field_on_draft}, keyboard::*, messages::*, - types::{ListingDraft, ListingDraftPersisted, ListingField}, + types::{ListingDraft, ListingField}, ui::enter_confirm_save_listing, }, - start::enter_main_menu, }, - db::{listing::ListingFields, user::PersistedUser, ListingDuration, ListingType, MoneyAmount}, + db::{user::PersistedUser, CurrencyType, ListingDuration, ListingType, MoneyAmount}, message_utils::*, HandlerResult, RootDialogue, }; use log::{error, info}; +use sqlx::SqlitePool; use teloxide::{types::CallbackQuery, Bot}; /// Handle callbacks during the listing type selection phase pub async fn handle_selecting_listing_type_callback( + db_pool: SqlitePool, bot: Bot, dialogue: RootDialogue, user: PersistedUser, @@ -34,7 +36,7 @@ pub async fn handle_selecting_listing_type_callback( info!("User {target:?} selected listing type: {data:?}"); if let Ok(NavKeyboardButtons::Back) = NavKeyboardButtons::try_from(data.as_str()) { - return enter_main_menu(bot, dialogue, target).await; + return enter_my_listings(db_pool, bot, dialogue, user, target, None).await; } // Parse the listing type from callback data @@ -107,17 +109,20 @@ pub async fn handle_awaiting_draft_field_callback( let button = StartTimeKeyboardButtons::try_from(data.as_str())?; handle_start_time_callback(&bot, dialogue, draft, button, target).await } - ListingField::Duration => { + ListingField::EndTime => { let button = DurationKeyboardButtons::try_from(data.as_str())?; handle_duration_callback(&bot, dialogue, draft, button, target).await } - ListingField::StartingBidAmount => { + ListingField::MinBidIncrement => { let button = EditMinimumBidIncrementKeyboardButtons::try_from(data.as_str())?; handle_starting_bid_amount_callback(&bot, dialogue, draft, button, target).await } + ListingField::CurrencyType => { + let button = CurrencyTypeKeyboardButtons::try_from(data.as_str())?; + handle_currency_type_callback(&bot, dialogue, draft, button, target).await + } _ => { error!("Unknown callback data for field {field:?}: {data}"); - dialogue.exit().await?; Ok(()) } } @@ -162,12 +167,12 @@ async fn handle_slots_callback( SlotsKeyboardButtons::TenSlots => 10, }; - match &mut draft.fields { - ListingFields::FixedPriceListing(fields) => { - fields.slots_available = num_slots; - } - _ => anyhow::bail!("Cannot update slots for non-fixed price listing"), - } + update_field_on_draft( + ListingField::Slots, + &mut draft, + Some(num_slots.to_string().as_str()), + ) + .map_err(|e| anyhow::anyhow!("Error updating slots: {e:?}"))?; let response = format!( "βœ… Available slots: {num_slots}\n\n{}", @@ -196,25 +201,24 @@ async fn handle_start_time_callback( StartTimeKeyboardButtons::Now => ListingDuration::zero(), }; - match &mut draft.persisted { - ListingDraftPersisted::New(fields) => { - fields.start_delay = start_time; - } - ListingDraftPersisted::Persisted(_) => { - anyhow::bail!("Cannot update start time for persisted listing"); - } - } + update_field_on_draft( + ListingField::StartTime, + &mut draft, + Some(start_time.to_string().as_str()), + ) + .map_err(|e| anyhow::anyhow!("Error updating start time: {e:?}"))?; let response = format!( - "βœ… Listing will start: immediately\n\n{}", - get_step_message(ListingField::Duration, draft.listing_type()) + "βœ… Listing will start: {}\n\n{}", + start_time, + get_step_message(ListingField::EndTime, draft.listing_type()) ); - transition_to_field(dialogue, ListingField::Duration, draft).await?; + transition_to_field(dialogue, ListingField::EndTime, draft).await?; send_message( bot, target, &response, - get_keyboard_for_field(ListingField::Duration), + get_keyboard_for_field(ListingField::EndTime), ) .await?; Ok(()) @@ -235,16 +239,14 @@ async fn handle_duration_callback( DurationKeyboardButtons::FourteenDays => 14, }); - match &mut draft.persisted { - ListingDraftPersisted::New(fields) => { - fields.end_delay = duration; - } - ListingDraftPersisted::Persisted(_) => { - anyhow::bail!("Cannot update duration for persisted listing"); - } - } + update_field_on_draft( + ListingField::EndTime, + &mut draft, + Some(duration.to_string().as_str()), + ) + .map_err(|e| anyhow::anyhow!("Error updating duration: {e:?}"))?; - let flash = get_success_message(ListingField::Duration, draft.listing_type()); + let flash = get_success_message(ListingField::EndTime, draft.listing_type()); enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await } @@ -261,17 +263,49 @@ async fn handle_starting_bid_amount_callback( EditMinimumBidIncrementKeyboardButtons::TenDollars => "10.00", })?; - match &mut draft.fields { - ListingFields::BasicAuction(fields) => { - fields.starting_bid = starting_bid_amount; - } - _ => anyhow::bail!("Cannot update starting bid amount for non-basic auction listing"), - } + update_field_on_draft( + ListingField::StartingBidAmount, + &mut draft, + Some(starting_bid_amount.to_string().as_str()), + ) + .map_err(|e| anyhow::anyhow!("Error updating starting bid amount: {e:?}"))?; let flash = get_success_message(ListingField::StartingBidAmount, draft.listing_type()); enter_confirm_save_listing(bot, dialogue, target, draft, Some(flash)).await } +async fn handle_currency_type_callback( + bot: &Bot, + dialogue: RootDialogue, + mut draft: ListingDraft, + button: CurrencyTypeKeyboardButtons, + target: MessageTarget, +) -> HandlerResult { + let currency_type = match button { + CurrencyTypeKeyboardButtons::Usd => CurrencyType::Usd, + CurrencyTypeKeyboardButtons::Cad => CurrencyType::Cad, + CurrencyTypeKeyboardButtons::Gbp => CurrencyType::Gbp, + CurrencyTypeKeyboardButtons::Eur => CurrencyType::Eur, + }; + + update_field_on_draft( + ListingField::CurrencyType, + &mut draft, + Some(currency_type.to_string().as_str()), + ) + .map_err(|e| anyhow::anyhow!("Error updating currency type: {e:?}"))?; + + let next_field = ListingField::StartingBidAmount; + let response = format!( + "βœ… Listing will use currency: {}\n\n{}", + currency_type, + get_step_message(next_field, draft.listing_type()) + ); + transition_to_field(dialogue, next_field, draft).await?; + send_message(bot, target, &response, get_keyboard_for_field(next_field)).await?; + Ok(()) +} + /// Cancel the wizard and exit pub async fn cancel_wizard( bot: Bot, diff --git a/src/commands/new_listing/field_processing.rs b/src/commands/new_listing/field_processing.rs index a6f9122..f3eec77 100644 --- a/src/commands/new_listing/field_processing.rs +++ b/src/commands/new_listing/field_processing.rs @@ -3,10 +3,10 @@ //! This module handles the core logic for processing and updating listing fields //! during both initial creation and editing workflows. +use crate::commands::new_listing::messages::step_for_field; use crate::commands::new_listing::{types::NewListingState, validations::*}; use crate::{ - commands::new_listing::types::{ListingDraft, ListingDraftPersisted, ListingField}, - db::listing::ListingFields, + commands::new_listing::types::{ListingDraft, ListingField}, HandlerResult, RootDialogue, }; @@ -22,84 +22,15 @@ pub async fn transition_to_field( Ok(()) } -#[derive(Debug, Clone)] -pub enum UpdateFieldError { - ValidationError(String), - UnsupportedFieldType(ListingField), - FrozenField(ListingField), -} - /// Process field input and update the draft pub fn update_field_on_draft( field: ListingField, draft: &mut ListingDraft, - text: &str, -) -> Result<(), UpdateFieldError> { - match field { - ListingField::Title => { - draft.base.title = validate_title(text).map_err(UpdateFieldError::ValidationError)?; - } - ListingField::Description => { - draft.base.description = - Some(validate_description(text).map_err(UpdateFieldError::ValidationError)?); - } - ListingField::Price => match &mut draft.fields { - ListingFields::FixedPriceListing(fields) => { - fields.buy_now_price = - validate_price(text).map_err(UpdateFieldError::ValidationError)?; - } - _ => return Err(UpdateFieldError::UnsupportedFieldType(field)), - }, - ListingField::Slots => match &mut draft.fields { - ListingFields::FixedPriceListing(fields) => { - fields.slots_available = - validate_slots(text).map_err(UpdateFieldError::ValidationError)?; - } - _ => return Err(UpdateFieldError::UnsupportedFieldType(field)), - }, - ListingField::StartTime => match &mut draft.persisted { - ListingDraftPersisted::New(fields) => { - fields.start_delay = - validate_start_time(text).map_err(UpdateFieldError::ValidationError)?; - } - _ => return Err(UpdateFieldError::FrozenField(field)), - }, - ListingField::Duration => match &mut draft.persisted { - ListingDraftPersisted::New(fields) => { - fields.end_delay = - validate_duration(text).map_err(UpdateFieldError::ValidationError)?; - } - _ => return Err(UpdateFieldError::FrozenField(field)), - }, - ListingField::StartingBidAmount => match &mut draft.fields { - ListingFields::BasicAuction(fields) => { - fields.starting_bid = - validate_price(text).map_err(UpdateFieldError::ValidationError)?; - } - _ => return Err(UpdateFieldError::UnsupportedFieldType(field)), - }, - ListingField::BuyNowPrice => match &mut draft.fields { - ListingFields::BasicAuction(fields) => { - fields.buy_now_price = - Some(validate_price(text).map_err(UpdateFieldError::ValidationError)?); - } - _ => return Err(UpdateFieldError::UnsupportedFieldType(field)), - }, - ListingField::MinBidIncrement => match &mut draft.fields { - ListingFields::BasicAuction(fields) => { - fields.min_increment = - validate_price(text).map_err(UpdateFieldError::ValidationError)?; - } - _ => return Err(UpdateFieldError::UnsupportedFieldType(field)), - }, - ListingField::AntiSnipeMinutes => match &mut draft.fields { - ListingFields::BasicAuction(fields) => { - fields.anti_snipe_minutes = Some( - validate_anti_snipe_minutes(text).map_err(UpdateFieldError::ValidationError)?, - ); - } - _ => return Err(UpdateFieldError::UnsupportedFieldType(field)), - }, - }; + text: Option<&str>, +) -> Result<(), SetFieldError> { + let step = step_for_field(field, draft.listing_type()) + .ok_or(SetFieldError::UnsupportedFieldForListingType)?; + (step.set_field_value)(draft, text.map(|s| s.trim().to_string()))?; + draft.has_changes = true; Ok(()) } diff --git a/src/commands/new_listing/handlers.rs b/src/commands/new_listing/handlers.rs index 65ec818..a74ba93 100644 --- a/src/commands/new_listing/handlers.rs +++ b/src/commands/new_listing/handlers.rs @@ -7,8 +7,7 @@ use crate::{ commands::{ my_listings::enter_my_listings, new_listing::{ - callbacks::cancel_wizard, - field_processing::{transition_to_field, update_field_on_draft, UpdateFieldError}, + field_processing::{transition_to_field, update_field_on_draft}, keyboard::{ ConfirmationKeyboardButtons, DurationKeyboardButtons, FieldSelectionKeyboardButtons, SlotsKeyboardButtons, StartTimeKeyboardButtons, @@ -16,14 +15,15 @@ use crate::{ messages::{ get_edit_success_message, get_keyboard_for_field, get_listing_type_keyboard, get_listing_type_selection_message, get_next_field, get_step_message, - get_success_message, + get_success_message, step_for_field, }, - types::{ListingDraft, ListingDraftPersisted, ListingField, NewListingState}, + types::{ListingDraft, ListingField, NewListingState}, ui::{display_listing_summary, enter_confirm_save_listing}, + validations::SetFieldError, }, }, db::{ - listing::{ListingFields, NewListing, PersistedListing}, + listing::{NewListing, PersistedListing}, user::PersistedUser, ListingDAO, }, @@ -73,25 +73,19 @@ pub async fn handle_awaiting_draft_field_input( target: MessageTarget, msg: Message, ) -> HandlerResult { - let text = msg.text().unwrap_or(""); - info!("User {target:?} entered input step: {field:?}"); - if is_cancel(text) { - return cancel_wizard(bot, dialogue, target).await; - } - // Process the field update - match update_field_on_draft(field, &mut draft, text) { + match update_field_on_draft(field, &mut draft, msg.text()) { Ok(()) => (), - Err(UpdateFieldError::ValidationError(e)) => { + Err(SetFieldError::ValidationFailed(e)) => { send_message(&bot, target, e.clone(), None).await?; return Ok(()); } - Err(UpdateFieldError::UnsupportedFieldType(field)) => { + Err(SetFieldError::UnsupportedFieldForListingType) => { bail!("Cannot update field {field:?} for listing type"); } - Err(UpdateFieldError::FrozenField(field)) => { + Err(SetFieldError::FieldRequired) => { bail!("Cannot update field {field:?} on existing listing"); } }; @@ -120,26 +114,23 @@ pub async fn handle_editing_field_input( target: MessageTarget, msg: Message, ) -> HandlerResult { - let text = msg.text().unwrap_or("").trim(); - info!("User {target:?} editing field {field:?}"); // Process the field update - match update_field_on_draft(field, &mut draft, text) { + match update_field_on_draft(field, &mut draft, msg.text()) { Ok(()) => (), - Err(UpdateFieldError::ValidationError(e)) => { + Err(SetFieldError::ValidationFailed(e)) => { send_message(&bot, target, e.clone(), None).await?; return Ok(()); } - Err(UpdateFieldError::UnsupportedFieldType(_)) => { - bail!("Cannot update field for listing type"); + Err(SetFieldError::UnsupportedFieldForListingType) => { + bail!("Cannot update field {field:?} for listing type"); } - Err(UpdateFieldError::FrozenField(_)) => { - bail!("Cannot update field on existing listing"); + Err(SetFieldError::FieldRequired) => { + bail!("Cannot update field {field:?} on existing listing"); } }; - draft.has_changes = true; let flash = get_edit_success_message(field, draft.listing_type()); enter_edit_listing_draft(&bot, target, draft, dialogue, Some(flash)).await?; Ok(()) @@ -210,7 +201,7 @@ pub async fn handle_editing_draft_callback( FieldSelectionKeyboardButtons::Price => ListingField::Price, FieldSelectionKeyboardButtons::Slots => ListingField::Slots, FieldSelectionKeyboardButtons::StartTime => ListingField::StartTime, - FieldSelectionKeyboardButtons::Duration => ListingField::Duration, + FieldSelectionKeyboardButtons::Duration => ListingField::EndTime, FieldSelectionKeyboardButtons::Done => { return Err(anyhow::anyhow!("Done button should not be used here")) } @@ -279,31 +270,28 @@ pub async fn enter_edit_listing_draft( /// Save the listing to the database async fn save_listing(db_pool: &SqlitePool, draft: ListingDraft) -> HandlerResult { - let (listing, success_message) = match draft.persisted { - ListingDraftPersisted::New(fields) => { - let listing = ListingDAO::insert_listing( - db_pool, - NewListing { - persisted: fields, - base: draft.base, - fields: draft.fields, - }, - ) - .await?; - (listing, "Listing created!") - } - ListingDraftPersisted::Persisted(fields) => { - let listing = ListingDAO::update_listing( - db_pool, - PersistedListing { - persisted: fields, - base: draft.base, - fields: draft.fields, - }, - ) - .await?; - (listing, "Listing updated!") - } + let (listing, success_message) = if let Some(fields) = draft.persisted { + let listing = ListingDAO::update_listing( + db_pool, + PersistedListing { + persisted: fields, + base: draft.base, + fields: draft.fields, + }, + ) + .await?; + (listing, "Listing updated!") + } else { + let listing = ListingDAO::insert_listing( + db_pool, + NewListing { + persisted: (), + base: draft.base, + fields: draft.fields, + }, + ) + .await?; + (listing, "Listing created!") }; Ok(format!("βœ… {success_message}: {}", listing.base.title)) @@ -314,62 +302,14 @@ fn get_current_field_value( draft: &ListingDraft, field: ListingField, ) -> Result { - let value = match field { - ListingField::Title => draft.base.title.clone(), - ListingField::Description => draft - .base - .description - .as_deref() - .unwrap_or("(no description)") - .to_string(), - ListingField::Price => match &draft.fields { - ListingFields::FixedPriceListing(fields) => { - format!("${}", fields.buy_now_price) - } - _ => anyhow::bail!("Cannot update price for non-fixed price listing"), - }, - ListingField::Slots => match &draft.fields { - ListingFields::FixedPriceListing(fields) => { - format!("{} slots", fields.slots_available) - } - _ => anyhow::bail!("Cannot update slots for non-fixed price listing"), - }, - ListingField::StartTime => match &draft.persisted { - ListingDraftPersisted::New(fields) => { - format!("{} hours", fields.start_delay) - } - _ => anyhow::bail!("Cannot update start time of an existing listing"), - }, - ListingField::Duration => match &draft.persisted { - ListingDraftPersisted::New(fields) => fields.end_delay.to_string(), - _ => anyhow::bail!("Cannot update duration of an existing listing"), - }, - ListingField::StartingBidAmount => match &draft.fields { - ListingFields::BasicAuction(fields) => { - format!("${}", fields.starting_bid) - } - _ => anyhow::bail!("Cannot update starting bid amount for non-basic auction"), - }, - ListingField::BuyNowPrice => match &draft.fields { - ListingFields::BasicAuction(fields) => { - format!("${:?}", fields.buy_now_price) - } - _ => anyhow::bail!("Cannot update buy now price for non-basic auction"), - }, - ListingField::MinBidIncrement => match &draft.fields { - ListingFields::BasicAuction(fields) => { - format!("${}", fields.min_increment) - } - _ => anyhow::bail!("Cannot update min bid increment for non-basic auction"), - }, - ListingField::AntiSnipeMinutes => match &draft.fields { - ListingFields::BasicAuction(fields) => { - format!("{} minutes", fields.anti_snipe_minutes.unwrap_or(5)) - } - _ => anyhow::bail!("Cannot update anti-snipe minutes for non-basic auction"), - }, - }; - Ok(value) + let step = step_for_field(field, draft.listing_type()) + .ok_or_else(|| anyhow::anyhow!("Cannot get field value for field {field:?}"))?; + match (step.get_field_value)(draft) { + Ok(value) => Ok(value.unwrap_or_else(|| "(none)".to_string())), + Err(e) => Err(anyhow::anyhow!( + "Cannot get field value for field {field:?}: {e:?}" + )), + } } /// Get the edit keyboard for a field @@ -389,7 +329,7 @@ fn get_edit_keyboard_for_field(field: ListingField) -> InlineKeyboardMarkup { } ListingField::StartTime => back_button .append_row(StartTimeKeyboardButtons::to_keyboard().inline_keyboard[0].clone()), - ListingField::Duration => back_button + ListingField::EndTime => back_button .append_row(DurationKeyboardButtons::to_keyboard().inline_keyboard[0].clone()), _ => back_button, } diff --git a/src/commands/new_listing/keyboard.rs b/src/commands/new_listing/keyboard.rs index 41b9846..89f6ec0 100644 --- a/src/commands/new_listing/keyboard.rs +++ b/src/commands/new_listing/keyboard.rs @@ -8,6 +8,19 @@ keyboard_buttons! { } } +keyboard_buttons! { + pub enum CurrencyTypeKeyboardButtons { + [ + Usd("πŸ‡ΊπŸ‡Έ USD", "currency_type_usd"), + Cad("πŸ‡¨πŸ‡¦ CAD", "currency_type_cad"), + ], + [ + Gbp("πŸ‡¬πŸ‡§ GBP", "currency_type_gbp"), + Eur("πŸ‡ͺπŸ‡Ί EUR", "currency_type_eur"), + ], + } +} + keyboard_buttons! { pub enum DurationKeyboardButtons { OneDay("1 day", "duration_1_day"), @@ -52,7 +65,7 @@ keyboard_buttons! { ], [ Done("βœ… Done", "edit_done"), - ] + ], } } diff --git a/src/commands/new_listing/messages.rs b/src/commands/new_listing/messages.rs index b3dc2bc..8d713f5 100644 --- a/src/commands/new_listing/messages.rs +++ b/src/commands/new_listing/messages.rs @@ -3,134 +3,264 @@ //! This module centralizes all user-facing messages to eliminate duplication //! and provide a single source of truth for wizard text. -use crate::commands::new_listing::keyboard::*; use crate::commands::new_listing::types::ListingField; +use crate::commands::new_listing::validations::*; +use crate::commands::new_listing::{keyboard::*, ListingDraft}; +use crate::db::listing::ListingFields; use crate::db::ListingType; +use crate::message_utils::format_datetime; use teloxide::types::InlineKeyboardMarkup; -struct ListingStep { - field_type: ListingField, - field_name: &'static str, - description: &'static [&'static str], +#[derive(Debug)] +pub enum GetFieldError { + UnsupportedListingType, +} +pub type GetFieldResult = Result, GetFieldError>; + +#[derive(Copy, Clone)] +pub struct ListingStepData { + pub field_type: ListingField, + pub field_name: &'static str, + pub description: &'static [&'static str], + pub get_field_value: fn(&ListingDraft) -> GetFieldResult, + pub set_field_value: fn(&mut ListingDraft, Option) -> Result<(), SetFieldError>, } -const FIXED_PRICE_LISTING_STEPS: &[ListingStep] = &[ - ListingStep { - field_type: ListingField::Title, - field_name: "Title", - description: &["Please enter a title for your listing (max 100 characters):"], +macro_rules! get_field_mut { + ($fields:expr, $($variant:ident { $field:ident }),+) => { + match &mut $fields { + $( + get_field_mut!(@field_name $variant fields) => &mut fields.$field, + )+ + _ => return Err(SetFieldError::UnsupportedFieldForListingType), + } + }; + (@field_name BasicAuctionFields $f:ident) => { ListingFields::BasicAuction($f) }; + (@field_name MultiSlotAuctionFields $f:ident) => { ListingFields::MultiSlotAuction($f) }; + (@field_name FixedPriceListingFields $f:ident) => { ListingFields::FixedPriceListing($f) }; + (@field_name BlindAuctionFields $f:ident) => { ListingFields::BlindAuction($f) }; +} + +macro_rules! get_field { + ($fields:expr, $($variant:ident { $field:ident }),+) => { + match &$fields { + $( + get_field!(@field_name $variant fields) => Ok(fields.$field), + )+ + _ => Err(GetFieldError::UnsupportedListingType), + } + }; + (@field_name BasicAuctionFields $f:ident) => { ListingFields::BasicAuction($f) }; + (@field_name MultiSlotAuctionFields $f:ident) => { ListingFields::MultiSlotAuction($f) }; + (@field_name FixedPriceListingFields $f:ident) => { ListingFields::FixedPriceListing($f) }; + (@field_name BlindAuctionFields $f:ident) => { ListingFields::BlindAuction($f) }; +} + +const TITLE_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::Title, + field_name: "Title", + description: &["Please enter a title for your listing (max 100 characters)"], + get_field_value: |draft| Ok(Some(draft.base.title.clone())), + set_field_value: |draft, value| { + draft.base.title = require_field(value).and_then(validate_title)?; + Ok(()) }, - ListingStep { - field_type: ListingField::Description, - field_name: "Description", - description: &["Please enter a description for your listing (optional)."], +}; + +const DESCRIPTION_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::Description, + field_name: "Description", + description: &["Please enter a description for your listing (optional)"], + get_field_value: |draft| Ok(draft.base.description.clone()), + set_field_value: |draft, value| { + draft.base.description = value.map(validate_description).transpose()?; + Ok(()) }, - ListingStep { - field_type: ListingField::Price, - field_name: "Price", - description: &[ - "Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99):", - "β€’ Price should be in USD", - ], +}; + +const CURRENCY_TYPE_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::CurrencyType, + field_name: "Currency Type", + description: &["Please enter the currency type for your listing (optional)"], + get_field_value: |draft| Ok(Some(draft.base.currency_type.to_string())), + set_field_value: |draft, value| { + draft.base.currency_type = require_field(value).and_then(validate_currency_type)?; + Ok(()) }, - ListingStep { - field_type: ListingField::Slots, - field_name: "Slots", - description: &[ - "How many items are available for sale?", - "β€’ Choose a common value below or enter a custom number (1-1000):", - ], +}; + +const START_TIME_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::StartTime, + field_name: "Start Time", + description: &[ + "When should your listing start?", + "β€’ Click 'Now' to start immediately", + "β€’ Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)", + "β€’ Maximum delay: 168 hours (7 days)", + ], + get_field_value: |draft| { + Ok(Some(format_datetime(draft.base.starts_at))) }, - ListingStep { - field_type: ListingField::StartTime, - field_name: "Start Time", - description: &[ - "When should your listing start?", - "β€’ Click 'Now' to start immediately", - "β€’ Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)", - "β€’ Maximum delay: 168 hours (7 days)", - ], + set_field_value: |draft, value| { + draft.base.starts_at = require_field(value).and_then(validate_start_time)?; + Ok(()) }, - ListingStep { - field_type: ListingField::Duration, - field_name: "Duration", - description: &[ - "How long should your listing run?", - "β€’ Enter duration in hours (minimum 1 hour, maximum 720 hours / 30 days):", - ], +}; + +const DURATION_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::EndTime, + field_name: "End Time", + description: &[ + "When should your listing end?", + "β€’ Enter duration in hours (minimum 1 hour, maximum 720 hours / 30 days):", + ], + get_field_value: |draft| Ok(Some(format_datetime(draft.base.ends_at))), + set_field_value: |draft, value| { + draft.base.ends_at = + draft.base.starts_at + require_field(value).and_then(validate_duration)?; + Ok(()) }, +}; + +const PRICE_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::Price, + field_name: "Price", + description: &[ + "Please enter the fixed price for your listing (e.g., 10.50, 25, 0.99)", + "β€’ Price should be in USD", + ], + get_field_value: |draft| { + let buy_now_price = get_field!(draft.fields, FixedPriceListingFields { buy_now_price })?; + Ok(Some(format!("${buy_now_price}"))) + }, + set_field_value: |draft, value| { + *get_field_mut!(draft.fields, FixedPriceListingFields { buy_now_price }) = + require_field(value).and_then(validate_price)?; + Ok(()) + }, +}; + +const SLOTS_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::Slots, + field_name: "Slots", + description: &[ + "How many items are available for sale?", + "β€’ Choose a common value below or enter a custom number (1-1000)", + ], + get_field_value: |draft| { + let slots_available = + get_field!(draft.fields, FixedPriceListingFields { slots_available })?; + Ok(Some(format!("{slots_available} slots"))) + }, + set_field_value: |draft, value| { + *get_field_mut!(draft.fields, FixedPriceListingFields { slots_available }) = + require_field(value).and_then(validate_slots)?; + Ok(()) + }, +}; + +const STARTING_BID_AMOUNT_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::StartingBidAmount, + field_name: "Starting Bid Amount", + description: &[ + "Please enter the starting bid amount for your auction (e.g., 10.50, 25, 0.99):", + "β€’ Starting bid should be in USD", + ], + get_field_value: |draft| { + let starting_bid_amount = get_field!(draft.fields, BasicAuctionFields { starting_bid })?; + Ok(Some(format!("${starting_bid_amount}"))) + }, + set_field_value: |draft, value| { + *get_field_mut!(draft.fields, BasicAuctionFields { starting_bid }) = + require_field(value).and_then(validate_price)?; + Ok(()) + }, +}; + +const BUY_NOW_PRICE_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::BuyNowPrice, + field_name: "Buy Now Price", + description: &[ + "Please enter the buy now price for your auction (e.g., 10.50, 25, 0.99):", + "β€’ Buy now price should be in USD", + ], + get_field_value: |draft| { + let buy_now_price = get_field!(draft.fields, BasicAuctionFields { buy_now_price })?; + Ok(buy_now_price.map(|price| format!("${price}"))) + }, + set_field_value: |draft, value| { + *get_field_mut!(draft.fields, BasicAuctionFields { buy_now_price }) = + value.map(validate_price).transpose()?; + Ok(()) + }, +}; + +const BID_INCREMENT_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::MinBidIncrement, + field_name: "Minimum Bid Increment", + description: &[ + "Please enter the minimum bid increment for your auction (e.g., 10.50, 25, 0.99):", + "β€’ Default: $1.00", + "β€’ Minimum bid increment should be in USD", + ], + get_field_value: |draft| { + let min_bid_increment = get_field!(draft.fields, BasicAuctionFields { min_increment })?; + Ok(Some(format!("${min_bid_increment}"))) + }, + set_field_value: |draft, value| { + *get_field_mut!(draft.fields, BasicAuctionFields { min_increment }) = + require_field(value).and_then(validate_price)?; + Ok(()) + }, +}; + +const ANTI_SNIPE_MINUTES_LISTING_STEP_DATA: ListingStepData = ListingStepData { + field_type: ListingField::AntiSnipeMinutes, + field_name: "Anti-Snipe Minutes", + description: &[ + "Please enter the anti-snipe minutes for your auction (e.g., 10, 15, 20):", + "β€’ Default: 5 minutes", + "β€’ Anti-snipe will extend the auction duration by the number of minutes entered", + "β€’ Anti-snipe minutes should be in minutes", + ], + get_field_value: |draft| { + let anti_snipe_minutes = + get_field!(draft.fields, BasicAuctionFields { anti_snipe_minutes })?; + Ok(anti_snipe_minutes.map(|minutes| format!("{minutes} minutes"))) + }, + set_field_value: |draft, value| { + *get_field_mut!(draft.fields, BasicAuctionFields { anti_snipe_minutes }) = + value.map(validate_anti_snipe_minutes).transpose()?; + Ok(()) + }, +}; + +const FIXED_PRICE_LISTING_STEPS: &[ListingStepData] = &[ + TITLE_LISTING_STEP_DATA, + DESCRIPTION_LISTING_STEP_DATA, + CURRENCY_TYPE_LISTING_STEP_DATA, + PRICE_LISTING_STEP_DATA, + SLOTS_LISTING_STEP_DATA, + START_TIME_LISTING_STEP_DATA, + DURATION_LISTING_STEP_DATA, ]; -const BASIC_AUCTION_STEPS: &[ListingStep] = &[ - ListingStep { - field_type: ListingField::Title, - description: &["Please enter a title for your listing (max 100 characters):"], - field_name: "Title", - }, - ListingStep { - field_type: ListingField::Description, - description: &["Please enter a description for your listing (optional)."], - field_name: "Description", - }, - ListingStep { - field_type: ListingField::StartingBidAmount, - field_name: "Starting Bid Amount", - description: &[ - "Please enter the starting bid amount for your auction (e.g., 10.50, 25, 0.99):", - "β€’ Starting bid should be in USD", - ], - }, - ListingStep { - field_type: ListingField::BuyNowPrice, - field_name: "Buy Now Price", - description: &[ - "Please enter the buy now price for your auction (e.g., 10.50, 25, 0.99):", - "β€’ Buy now price should be in USD", - ], - }, - ListingStep { - field_type: ListingField::MinBidIncrement, - field_name: "Minimum Bid Increment", - description: &[ - "Please enter the minimum bid increment for your auction (e.g., 10.50, 25, 0.99):", - "β€’ Default: $1.00", - "β€’ Minimum bid increment should be in USD", - ], - }, - ListingStep { - field_type: ListingField::AntiSnipeMinutes, - field_name: "Anti-Snipe Minutes", - description: &[ - "Please enter the anti-snipe minutes for your auction (e.g., 10, 15, 20):", - "β€’ Default: 5 minutes", - "β€’ Anti-snipe will extend the auction duration by the number of minutes entered", - "β€’ Anti-snipe minutes should be in minutes", - ], - }, - ListingStep { - field_type: ListingField::StartTime, - field_name: "Start Time", - description: &[ - "When should the auction start?", - "β€’ Click 'Now' to start immediately", - "β€’ Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)", - "β€’ Maximum delay: 168 hours (7 days)", - ], - }, - ListingStep { - field_type: ListingField::Duration, - field_name: "Duration", - description: &[ - "How long should your auction run?", - "β€’ Click 'Now' to start immediately", - "β€’ Enter duration to delay (e.g., '2 days' for 2 days from now, '1 hour' for 1 hour from now)", - ], - }, +const BASIC_AUCTION_STEPS: &[ListingStepData] = &[ + TITLE_LISTING_STEP_DATA, + DESCRIPTION_LISTING_STEP_DATA, + CURRENCY_TYPE_LISTING_STEP_DATA, + STARTING_BID_AMOUNT_LISTING_STEP_DATA, + BUY_NOW_PRICE_LISTING_STEP_DATA, + BID_INCREMENT_LISTING_STEP_DATA, + ANTI_SNIPE_MINUTES_LISTING_STEP_DATA, + START_TIME_LISTING_STEP_DATA, + DURATION_LISTING_STEP_DATA, ]; -const BLIND_AUCTION_STEPS: &[ListingStep] = &[]; -const MULTI_SLOT_AUCTION_STEPS: &[ListingStep] = &[]; -fn steps_for_listing_type(listing_type: ListingType) -> &'static [ListingStep] { +const BLIND_AUCTION_STEPS: &[ListingStepData] = &[]; +const MULTI_SLOT_AUCTION_STEPS: &[ListingStepData] = &[]; + +pub fn steps_for_listing_type(listing_type: ListingType) -> &'static [ListingStepData] { match listing_type { ListingType::FixedPriceListing => FIXED_PRICE_LISTING_STEPS, ListingType::BasicAuction => BASIC_AUCTION_STEPS, @@ -148,7 +278,10 @@ fn get_total_steps(listing_type: ListingType) -> usize { steps_for_listing_type(listing_type).len() } -fn step_for_field(field: ListingField, listing_type: ListingType) -> Option<&'static ListingStep> { +pub fn step_for_field( + field: ListingField, + listing_type: ListingType, +) -> Option<&'static ListingStepData> { steps_for_listing_type(listing_type) .iter() .find(|step| step.field_type == field) @@ -202,10 +335,11 @@ pub fn get_keyboard_for_field(field: ListingField) -> Option Some(CurrencyTypeKeyboardButtons::to_keyboard()), ListingField::Price => None, ListingField::Slots => Some(SlotsKeyboardButtons::to_keyboard()), ListingField::StartTime => Some(StartTimeKeyboardButtons::to_keyboard()), - ListingField::Duration => Some(DurationKeyboardButtons::to_keyboard()), + ListingField::EndTime => Some(DurationKeyboardButtons::to_keyboard()), // TODO - Add keyboards for these fields ListingField::StartingBidAmount => None, ListingField::BuyNowPrice => Some(InlineKeyboardMarkup::new([[ @@ -233,5 +367,5 @@ pub fn get_listing_type_selection_message() -> &'static str { /// Get the keyboard for listing type selection pub fn get_listing_type_keyboard() -> InlineKeyboardMarkup { - ListingTypeKeyboardButtons::to_keyboard() + ListingTypeKeyboardButtons::to_keyboard().append_row([NavKeyboardButtons::Back.to_button()]) } diff --git a/src/commands/new_listing/mod.rs b/src/commands/new_listing/mod.rs index fd1e4b5..67cb1a2 100644 --- a/src/commands/new_listing/mod.rs +++ b/src/commands/new_listing/mod.rs @@ -16,7 +16,7 @@ mod callbacks; mod field_processing; mod handler_factory; mod handlers; -mod keyboard; +pub mod keyboard; pub mod messages; #[cfg(test)] mod tests; diff --git a/src/commands/new_listing/tests.rs b/src/commands/new_listing/tests.rs index 6271b08..d98e90f 100644 --- a/src/commands/new_listing/tests.rs +++ b/src/commands/new_listing/tests.rs @@ -1,22 +1,28 @@ +use chrono::Duration; + use crate::{ + assert_timestamps_approx_eq, commands::new_listing::{ field_processing::update_field_on_draft, - types::{ListingDraft, ListingDraftPersisted, ListingField}, + types::{ListingDraft, ListingField}, }, db::{ - listing::{FixedPriceListingFields, ListingFields, NewListingFields}, - ListingDuration, MoneyAmount, UserDbId, + listing::{FixedPriceListingFields, ListingFields}, + CurrencyType, MoneyAmount, UserDbId, }, }; fn create_test_draft() -> ListingDraft { ListingDraft { has_changes: false, - persisted: ListingDraftPersisted::New(NewListingFields::default()), + persisted: None, base: crate::db::listing::ListingBase { seller_id: UserDbId::new(1), title: "".to_string(), description: None, + currency_type: CurrencyType::Usd, + starts_at: chrono::Utc::now(), + ends_at: chrono::Utc::now() + chrono::Duration::hours(1), }, fields: ListingFields::FixedPriceListing(FixedPriceListingFields { buy_now_price: MoneyAmount::default(), @@ -40,12 +46,12 @@ fn test_complete_field_processing_workflow() { ), (ListingField::Price, "34.99"), (ListingField::Slots, "2"), - (ListingField::StartTime, "1"), - (ListingField::Duration, "3 days"), + (ListingField::StartTime, "1 hour"), + (ListingField::EndTime, "3 days"), ]; for (field, input) in workflow { - let result = update_field_on_draft(field, &mut draft, input); + let result = update_field_on_draft(field, &mut draft, Some(input)); assert!(result.is_ok(), "Processing {field:?} should succeed"); } @@ -66,50 +72,16 @@ fn test_complete_field_processing_workflow() { assert_eq!(fields.slots_available, 2); } - if let ListingDraftPersisted::New(fields) = &draft.persisted { - assert_eq!(fields.start_delay, ListingDuration::hours(1)); - assert_eq!(fields.end_delay, ListingDuration::hours(72)); // 3 days - } -} - -#[test] -fn test_persisted_listing_edit_restrictions() { - let mut draft = ListingDraft { - has_changes: false, - persisted: ListingDraftPersisted::Persisted(crate::db::listing::PersistedListingFields { - id: crate::db::ListingDbId::new(1), - start_at: chrono::Utc::now(), - end_at: chrono::Utc::now() + chrono::Duration::hours(24), - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - }), - base: crate::db::listing::ListingBase { - seller_id: UserDbId::new(1), - title: "Existing Listing".to_string(), - description: None, - }, - fields: ListingFields::FixedPriceListing(FixedPriceListingFields { - buy_now_price: MoneyAmount::from_str("10.00").unwrap(), - slots_available: 1, - }), - }; - - // Critical business rule: Cannot modify timing of live listings - let start_time_result = update_field_on_draft(ListingField::StartTime, &mut draft, "5"); - assert!( - start_time_result.is_err(), - "Live listings cannot change start time" + assert_timestamps_approx_eq!( + draft.base.starts_at, + chrono::Utc::now() + Duration::hours(1), + Duration::milliseconds(100) ); - - let duration_result = update_field_on_draft(ListingField::Duration, &mut draft, "48"); - assert!( - duration_result.is_err(), - "Live listings cannot change duration" + assert_timestamps_approx_eq!( + draft.base.ends_at, + chrono::Utc::now() + Duration::hours(72) + Duration::hours(1), + Duration::milliseconds(100) ); - - // But content can be updated - let title_result = update_field_on_draft(ListingField::Title, &mut draft, "Updated Title"); - assert!(title_result.is_ok(), "Live listings can update content"); } #[test] @@ -124,15 +96,14 @@ fn test_natural_language_duration_conversion() { ]; for (input, expected_hours) in business_durations { - update_field_on_draft(ListingField::Duration, &mut draft, input).unwrap(); + update_field_on_draft(ListingField::EndTime, &mut draft, Some(input)).unwrap(); - if let ListingDraftPersisted::New(fields) = &draft.persisted { - assert_eq!( - fields.end_delay, - ListingDuration::hours(expected_hours), - "Business duration '{input}' should convert correctly" - ); - } + assert_timestamps_approx_eq!( + draft.base.ends_at, + chrono::Utc::now() + chrono::Duration::hours(expected_hours), + Duration::milliseconds(100), + "Business duration '{input}' should convert correctly" + ); } } @@ -141,8 +112,8 @@ fn test_price_and_slots_consistency_for_fixed_price_listings() { let mut draft = create_test_draft(); // Test that price and slots work together correctly for business logic - update_field_on_draft(ListingField::Price, &mut draft, "25.00").unwrap(); - update_field_on_draft(ListingField::Slots, &mut draft, "5").unwrap(); + update_field_on_draft(ListingField::Price, &mut draft, Some("25.00")).unwrap(); + update_field_on_draft(ListingField::Slots, &mut draft, Some("5")).unwrap(); if let ListingFields::FixedPriceListing(fields) = &draft.fields { assert_eq!( diff --git a/src/commands/new_listing/types.rs b/src/commands/new_listing/types.rs index 4c1f223..26417da 100644 --- a/src/commands/new_listing/types.rs +++ b/src/commands/new_listing/types.rs @@ -2,19 +2,19 @@ use crate::{ db::{ listing::{ BasicAuctionFields, BlindAuctionFields, FixedPriceListingFields, ListingBase, - ListingFields, MultiSlotAuctionFields, NewListingFields, PersistedListing, - PersistedListingFields, + ListingFields, MultiSlotAuctionFields, PersistedListing, PersistedListingFields, }, - ListingType, MoneyAmount, UserDbId, + CurrencyType, ListingType, MoneyAmount, UserDbId, }, DialogueRootState, }; +use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct ListingDraft { pub has_changes: bool, - pub persisted: ListingDraftPersisted, + pub persisted: Option, pub base: ListingBase, pub fields: ListingFields, } @@ -50,11 +50,14 @@ impl ListingDraft { Self { has_changes: false, - persisted: ListingDraftPersisted::New(NewListingFields::default()), + persisted: None, base: ListingBase { seller_id, + currency_type: CurrencyType::Usd, title: "".to_string(), description: None, + starts_at: Utc::now(), + ends_at: Utc::now() + Duration::days(3), }, fields, } @@ -63,7 +66,7 @@ impl ListingDraft { pub fn from_persisted(listing: PersistedListing) -> Self { Self { has_changes: false, - persisted: ListingDraftPersisted::Persisted(listing.persisted), + persisted: Some(listing.persisted), base: listing.base, fields: listing.fields, } @@ -74,20 +77,15 @@ impl ListingDraft { } } -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub enum ListingDraftPersisted { - New(NewListingFields), - Persisted(PersistedListingFields), -} - #[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Debug)] pub enum ListingField { Title, Description, + CurrencyType, Price, Slots, StartTime, - Duration, + EndTime, StartingBidAmount, BuyNowPrice, MinBidIncrement, diff --git a/src/commands/new_listing/ui.rs b/src/commands/new_listing/ui.rs index eba4290..25abdf5 100644 --- a/src/commands/new_listing/ui.rs +++ b/src/commands/new_listing/ui.rs @@ -4,14 +4,11 @@ //! listing summaries, confirmation screens, and edit interfaces. use crate::commands::new_listing::keyboard::ConfirmationKeyboardButtons; +use crate::commands::new_listing::messages::steps_for_listing_type; use crate::commands::new_listing::NewListingState; +use crate::db::ListingType; use crate::RootDialogue; -use crate::{ - commands::new_listing::types::{ListingDraft, ListingDraftPersisted}, - db::listing::ListingFields, - message_utils::*, - HandlerResult, -}; +use crate::{commands::new_listing::types::ListingDraft, message_utils::*, HandlerResult}; use teloxide::{types::InlineKeyboardMarkup, Bot}; /// Display the listing summary with optional flash message and keyboard @@ -23,6 +20,7 @@ pub async fn display_listing_summary( flash_message: Option, ) -> HandlerResult { let mut response_lines = vec![]; + let listing_type: ListingType = (&draft.fields).into(); if let Some(flash_message) = flash_message { response_lines.push(flash_message.to_string()); @@ -33,44 +31,21 @@ pub async fn display_listing_summary( } else { "" }; - response_lines.push(format!("πŸ“‹ Listing Summary {unsaved_changes}")); - response_lines.push("".to_string()); - response_lines.push(format!("Title: {}", draft.base.title)); response_lines.push(format!( - "πŸ“„ Description: {}", - draft - .base - .description - .as_deref() - .unwrap_or("No description") + "πŸ“‹ {listing_type} Summary {unsaved_changes}" )); + response_lines.push("".to_string()); - if let ListingFields::FixedPriceListing(fields) = &draft.fields { - response_lines.push(format!( - "πŸ’° Buy it Now Price: ${}", - fields.buy_now_price - )); - } - - match &draft.persisted { - ListingDraftPersisted::New(fields) => { - response_lines.push(format!("Start delay: {}", fields.start_delay)); - response_lines.push(format!("Duration: {}", fields.end_delay)); - } - ListingDraftPersisted::Persisted(fields) => { - response_lines.push(format!( - "Starts on: {}", - format_datetime(fields.start_at) - )); - response_lines.push(format!( - "Ends on: {}", - format_datetime(fields.end_at) - )); - } + for step in steps_for_listing_type(listing_type) { + let field_value = match (step.get_field_value)(draft) { + Ok(value) => value.unwrap_or_else(|| "(none)".to_string()), + Err(_) => continue, + }; + response_lines.push(format!("{}: {}", step.field_name, field_value)); } response_lines.push("".to_string()); - response_lines.push("Please review your listing and choose an action:".to_string()); + response_lines.push("Edit your listing:".to_string()); send_message(bot, target, response_lines.join("\n"), keyboard).await?; @@ -85,17 +60,18 @@ pub async fn enter_confirm_save_listing( draft: ListingDraft, flash: Option, ) -> HandlerResult { - let keyboard = match draft.persisted { - ListingDraftPersisted::New(_) => InlineKeyboardMarkup::default().append_row([ - ConfirmationKeyboardButtons::Create.to_button(), - ConfirmationKeyboardButtons::Edit.to_button(), - ConfirmationKeyboardButtons::Discard.to_button(), - ]), - ListingDraftPersisted::Persisted(_) => InlineKeyboardMarkup::default().append_row([ + let keyboard = if draft.persisted.is_some() { + InlineKeyboardMarkup::default().append_row([ ConfirmationKeyboardButtons::Save.to_button(), ConfirmationKeyboardButtons::Edit.to_button(), + ConfirmationKeyboardButtons::Discard.to_button(), + ]) + } else { + InlineKeyboardMarkup::default().append_row([ + ConfirmationKeyboardButtons::Create.to_button(), + ConfirmationKeyboardButtons::Edit.to_button(), ConfirmationKeyboardButtons::Cancel.to_button(), - ]), + ]) }; display_listing_summary(bot, target, &draft, Some(keyboard), flash).await?; diff --git a/src/commands/new_listing/validations.rs b/src/commands/new_listing/validations.rs index c3fc82a..ebf6213 100644 --- a/src/commands/new_listing/validations.rs +++ b/src/commands/new_listing/validations.rs @@ -1,126 +1,147 @@ -use crate::db::{ListingDuration, MoneyAmount}; +use chrono::{DateTime, Duration, Utc}; + +use crate::db::{CurrencyType, MoneyAmount}; + +#[derive(Debug)] +pub enum SetFieldError { + FieldRequired, + ValidationFailed(String), + UnsupportedFieldForListingType, +} + +type SetFieldResult = Result; + +fn validation_failed(message: &str) -> SetFieldResult { + Err(SetFieldError::ValidationFailed(message.to_string())) +} +macro_rules! validation_failed { + ($message:expr) => { + return validation_failed($message) + }; +} + +pub fn require_field(field: Option) -> SetFieldResult { + field.ok_or(SetFieldError::FieldRequired) +} // Common input validation functions -pub fn validate_title(text: &str) -> Result { +pub fn validate_title(text: impl AsRef) -> SetFieldResult { + let text = text.as_ref(); if text.is_empty() { - return Err("❌ Title cannot be empty. Please enter a title for your listing:".to_string()); + validation_failed!("❌ Title cannot be empty"); } if text.len() > 100 { - return Err( - "❌ Title is too long (max 100 characters). Please enter a shorter title:".to_string(), - ); + validation_failed!("❌ Title is too long (max 100 characters)"); } Ok(text.to_string()) } -pub fn validate_description(text: &str) -> Result { +pub fn validate_description(text: impl AsRef) -> SetFieldResult { + let text = text.as_ref(); if text.len() > 1000 { - return Err( - "❌ Description is too long (max 1000 characters). Please enter a shorter description:" - .to_string(), - ); + validation_failed!("❌ Description is too long (max 1000 characters)"); } Ok(text.to_string()) } -pub fn validate_price(text: &str) -> Result { +pub fn validate_currency_type(text: impl AsRef) -> SetFieldResult { + let text = text.as_ref(); + if let Ok(currency_type) = CurrencyType::try_from(text) { + Ok(currency_type) + } else { + validation_failed!("❌ Invalid currency type"); + } +} + +pub fn validate_price(text: impl AsRef) -> SetFieldResult { + let text = text.as_ref(); + match MoneyAmount::from_str(text) { Ok(amount) => { if amount.cents() <= 0 { - Err("❌ Price must be greater than $0.00. Please enter a valid price:".to_string()) + validation_failed!("❌ Price must be greater than $0.00"); } else { Ok(amount) } } - Err(_) => Err( - "❌ Invalid price format. Please enter a valid price (e.g., 10.50, 25, 0.99):" - .to_string(), + Err(_) => validation_failed!( + "❌ Invalid price format (use decimal format, e.g., 10.50, 25, 0.99):" ), } } -pub fn validate_slots(text: &str) -> Result { +pub fn validate_slots(text: impl AsRef) -> SetFieldResult { + let text = text.as_ref(); match text.parse::() { Ok(slots) if (1..=1000).contains(&slots) => Ok(slots), - Ok(_) => Err( - "❌ Number of slots must be between 1 and 1000. Please enter a valid number:" - .to_string(), - ), - Err(_) => Err("❌ Invalid number. Please enter a number from 1 to 1000:".to_string()), + Ok(_) => validation_failed!("❌ Number of slots must be between 1 and 1000"), + Err(_) => validation_failed!("❌ Invalid number. Please enter a number from 1 to 1000"), } } -pub fn validate_duration(text: &str) -> Result { +pub fn validate_time(text: impl AsRef, field_name: &str) -> SetFieldResult { + let text = text.as_ref(); let text = text.trim().to_lowercase(); // Try to parse as plain number first (backwards compatibility) if let Ok(hours) = text.parse::() { if (1..=720).contains(&hours) { - return Ok(ListingDuration::hours(hours)); + return Ok(Duration::hours(hours as i64)); } else { - return Err("❌ Duration must be between 1 hour and 30 days (720 hours). Please enter a valid duration:".to_string()); + validation_failed!(&format!( + "❌ {field_name} must be between 1 hour and 30 days (720 hours)" + )); } } // Parse natural language duration let parts: Vec<&str> = text.split_whitespace().collect(); - if parts.len() != 2 { - return Err( - "❌ Please enter duration like '1 hour', '7 days', or just hours (1-720):".to_string(), - ); - } - - let number_str = parts[0]; - let unit = parts[1]; + let (number_str, unit) = if parts.len() == 2 { + (parts[0], parts[1]) + } else if parts.len() == 1 { + (text.as_str(), "hour") + } else { + validation_failed!(&format!( + "❌ Please enter {field_name} like '1 hour', '7 days', or just hours (1-720)" + )); + }; let number = match number_str.parse::() { Ok(n) if n > 0 => n, - _ => { - return Err( - "❌ Duration number must be a positive integer. Please enter a valid duration:" - .to_string(), - ) - } + _ => validation_failed!(&format!( + "❌ {field_name} number must be a positive integer" + )), }; let hours = match unit { - "hour" | "hours" | "hr" | "hrs" => number, - "day" | "days" => number * 24, - _ => { - return Err( - "❌ Supported units: hour(s), day(s). Please enter a valid duration:".to_string(), - ) - } + "hour" | "hours" | "hr" | "hrs" | "h" | "hs" => number, + "day" | "days" | "d" | "ds" => number * 24, + _ => validation_failed!("❌ Supported units: hour(s), day(s)"), }; if (1..=720).contains(&hours) { - Ok(ListingDuration::hours(hours)) + Ok(Duration::hours(hours as i64)) } else { - Err("❌ Duration must be between 1 hour and 30 days (720 hours). Please enter a valid duration:".to_string()) + validation_failed!(&format!( + "❌ {field_name} must be between 1 hour and 30 days (720 hours)" + )); } } -pub fn validate_start_time(text: &str) -> Result { - match text.parse::() { - Ok(hours) if (0..=168).contains(&hours) => Ok(ListingDuration::hours(hours)), // Max 1 week delay - Ok(_) => Err( - "❌ Start time must be between 0 and 168 hours. Please enter a valid number:" - .to_string(), - ), - Err(_) => Err( - "❌ Invalid number. Please enter number of hours (0 for immediate start):".to_string(), - ), - } +pub fn validate_duration(text: impl AsRef) -> SetFieldResult { + validate_time(text, "Duration") } -pub fn validate_anti_snipe_minutes(text: &str) -> Result { +pub fn validate_start_time(text: impl AsRef) -> SetFieldResult> { + validate_time(text, "Start Time").map(|duration| Utc::now() + duration) +} + +pub fn validate_anti_snipe_minutes(text: impl AsRef) -> SetFieldResult { + let text = text.as_ref(); match text.parse::() { Ok(minutes) if (0..=1440).contains(&minutes) => Ok(minutes), - Ok(_) => Err( - "❌ Anti-snipe minutes must be between 0 and 1440. Please enter a valid number:" - .to_string(), - ), - Err(_) => Err("❌ Invalid number. Please enter a number from 0 to 1440:".to_string()), + Ok(_) => validation_failed!("❌ Anti-snipe minutes must be between 0 and 1440"), + Err(_) => validation_failed!("❌ Invalid number. Please enter a number from 0 to 1440"), } } @@ -130,13 +151,13 @@ mod tests { use rstest::rstest; #[rstest] - #[case("24", ListingDuration::hours(24))] // Plain number - #[case("1 hour", ListingDuration::hours(1))] - #[case("2 hours", ListingDuration::hours(2))] - #[case("1 day", ListingDuration::hours(24))] - #[case("7 days", ListingDuration::hours(168))] - #[case("30 days", ListingDuration::hours(720))] // Max 30 days - fn test_validate_duration_valid(#[case] input: &str, #[case] expected: ListingDuration) { + #[case("24", Duration::hours(24))] // Plain number + #[case("1 hour", Duration::hours(1))] + #[case("2 hours", Duration::hours(2))] + #[case("1 day", Duration::hours(24))] + #[case("7 days", Duration::hours(168))] + #[case("30 days", Duration::hours(720))] // Max 30 days + fn test_validate_duration_valid(#[case] input: &str, #[case] expected: Duration) { let result = validate_duration(input).unwrap(); assert_eq!(result, expected); } diff --git a/src/db/dao/listing_dao.rs b/src/db/dao/listing_dao.rs index 5067719..2aff9e0 100644 --- a/src/db/dao/listing_dao.rs +++ b/src/db/dao/listing_dao.rs @@ -3,7 +3,7 @@ //! Provides encapsulated CRUD operations for Listing entities use anyhow::Result; -use chrono::{Duration, Utc}; +use chrono::Utc; use itertools::Itertools; use sqlx::{sqlite::SqliteRow, FromRow, Row, SqlitePool}; use std::fmt::Debug; @@ -27,6 +27,7 @@ const LISTING_RETURN_FIELDS: &[&str] = &[ "listing_type", "title", "description", + "currency_type", "starts_at", "ends_at", "created_at", @@ -45,13 +46,11 @@ impl ListingDAO { listing: NewListing, ) -> Result { let now = Utc::now(); - let start_at = now + Into::::into(listing.persisted.start_delay); - let end_at = start_at + Into::::into(listing.persisted.end_delay); let binds = binds_for_listing(&listing) .push("seller_id", &listing.base.seller_id) - .push("starts_at", &start_at) - .push("ends_at", &end_at) + .push("starts_at", &listing.base.starts_at) + .push("ends_at", &listing.base.ends_at) .push("created_at", &now) .push("updated_at", &now); @@ -155,6 +154,7 @@ fn binds_for_base(base: &ListingBase) -> BindFields { BindFields::default() .push("title", &base.title) .push("description", &base.description) + .push("currency_type", &base.currency_type) } fn binds_for_fields(fields: &ListingFields) -> BindFields { @@ -187,8 +187,6 @@ impl FromRow<'_, SqliteRow> for PersistedListing { let listing_type = row.get("listing_type"); let persisted = PersistedListingFields { id: row.get("id"), - start_at: row.get("starts_at"), - end_at: row.get("ends_at"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), }; @@ -196,6 +194,9 @@ impl FromRow<'_, SqliteRow> for PersistedListing { seller_id: row.get("seller_id"), title: row.get("title"), description: row.get("description"), + currency_type: row.get("currency_type"), + starts_at: row.get("starts_at"), + ends_at: row.get("ends_at"), }; let fields = match listing_type { ListingType::BasicAuction => ListingFields::BasicAuction(BasicAuctionFields { diff --git a/src/db/models/listing.rs b/src/db/models/listing.rs index 09ebcc0..fe152d9 100644 --- a/src/db/models/listing.rs +++ b/src/db/models/listing.rs @@ -9,29 +9,21 @@ //! The main `Listing` enum ensures that only valid fields are accessible for each type. //! Database mapping is handled through `ListingRow` with conversion traits. -use crate::db::{ListingDbId, ListingDuration, ListingType, MoneyAmount, UserDbId}; +use crate::db::{CurrencyType, ListingDbId, ListingType, MoneyAmount, UserDbId}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fmt::Debug; -pub type NewListing = Listing; +pub type NewListing = Listing<()>; pub type PersistedListing = Listing; #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] pub struct PersistedListingFields { pub id: ListingDbId, - pub start_at: DateTime, - pub end_at: DateTime, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Default)] -pub struct NewListingFields { - pub start_delay: ListingDuration, - pub end_delay: ListingDuration, -} - /// Main listing/auction entity #[derive(Debug, Clone, Eq, PartialEq)] #[allow(unused)] @@ -41,6 +33,20 @@ pub struct Listing { pub fields: ListingFields, } +pub type ListingBaseFields<'a> = (&'a ListingBase, &'a ListingFields); +pub type ListingBaseFieldsMut<'a> = (&'a mut ListingBase, &'a mut ListingFields); + +impl<'a, P: Debug + Clone> Into> for &'a Listing

{ + fn into(self) -> ListingBaseFields<'a> { + (&self.base, &self.fields) + } +} +impl<'a, P: Debug + Clone> Into> for &'a mut Listing

{ + fn into(self) -> ListingBaseFieldsMut<'a> { + (&mut self.base, &mut self.fields) + } +} + /// Common fields shared by all listing types #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[allow(unused)] @@ -48,13 +54,16 @@ pub struct ListingBase { pub seller_id: UserDbId, pub title: String, pub description: Option, + pub starts_at: DateTime, + pub ends_at: DateTime, + pub currency_type: CurrencyType, } impl ListingBase { #[cfg(test)] pub fn with_fields(self, fields: ListingFields) -> NewListing { Listing { - persisted: NewListingFields::default(), + persisted: (), base: self, fields, } @@ -106,9 +115,9 @@ pub enum ListingFields { BlindAuction(BlindAuctionFields), } -impl From<&ListingFields> for ListingType { - fn from(fields: &ListingFields) -> ListingType { - match fields { +impl ListingFields { + pub fn listing_type(&self) -> ListingType { + match self { ListingFields::BasicAuction(_) => ListingType::BasicAuction, ListingFields::MultiSlotAuction(_) => ListingType::MultiSlotAuction, ListingFields::FixedPriceListing(_) => ListingType::FixedPriceListing, @@ -117,10 +126,17 @@ impl From<&ListingFields> for ListingType { } } +impl From<&ListingFields> for ListingType { + fn from(fields: &ListingFields) -> ListingType { + fields.listing_type() + } +} + #[cfg(test)] mod tests { use super::*; use crate::db::{ListingDAO, TelegramUserDbId}; + use chrono::Duration; use rstest::rstest; use sqlx::SqlitePool; @@ -173,11 +189,15 @@ mod tests { seller_id: UserDbId, title: impl Into, description: Option<&str>, + currency_type: CurrencyType, ) -> ListingBase { ListingBase { seller_id, title: title.into(), description: description.map(|s| s.to_string()), + currency_type, + starts_at: Utc::now(), + ends_at: Utc::now() + Duration::days(3), } } @@ -190,8 +210,13 @@ mod tests { async fn test_blind_auction_crud(#[case] fields: ListingFields) { let pool = create_test_pool().await; let seller_id = create_test_user(&pool, 99999.into(), Some("testuser")).await; - let new_listing = build_base_listing(seller_id, "Test Auction", Some("Test description")) - .with_fields(fields); + let new_listing = build_base_listing( + seller_id, + "Test Auction", + Some("Test description"), + CurrencyType::Usd, + ) + .with_fields(fields); // Insert using DAO let created_listing = ListingDAO::insert_listing(&pool, new_listing.clone()) diff --git a/src/db/models/listing_type.rs b/src/db/models/listing_type.rs index 353837f..503276b 100644 --- a/src/db/models/listing_type.rs +++ b/src/db/models/listing_type.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + /// Types of listings supported by the platform #[derive(Debug, Clone, PartialEq, Eq, Copy, sqlx::Type)] #[sqlx(type_name = "TEXT")] @@ -12,3 +14,29 @@ pub enum ListingType { /// Blind auction where seller chooses winner BlindAuction, } + +impl ListingType { + pub fn as_str(&self) -> &'static str { + match self { + ListingType::FixedPriceListing => "Fixed Price Listing", + ListingType::BasicAuction => "Basic Auction", + ListingType::MultiSlotAuction => "Multi-Slot Auction", + ListingType::BlindAuction => "Blind Auction", + } + } + + pub fn emoji_str(&self) -> &'static str { + match self { + ListingType::FixedPriceListing => "πŸ›οΈ", + ListingType::BasicAuction => "⏰", + ListingType::MultiSlotAuction => "🎯", + ListingType::BlindAuction => "🎭", + } + } +} + +impl Display for ListingType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} diff --git a/src/db/types/currency_type.rs b/src/db/types/currency_type.rs index 744f420..2f79ef3 100644 --- a/src/db/types/currency_type.rs +++ b/src/db/types/currency_type.rs @@ -1,12 +1,17 @@ +use serde::{Deserialize, Serialize}; use sqlx::{ encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, }; /// Currency types supported by the platform -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum CurrencyType { #[default] - Usd, + Usd, // United States Dollar + Cad, // Canadian Dollar + Gbp, // British Pound + Eur, // Euro + Jpy, // Japanese Yen } #[allow(unused)] @@ -15,6 +20,10 @@ impl CurrencyType { pub fn as_str(&self) -> &'static str { match self { CurrencyType::Usd => "USD", + CurrencyType::Cad => "CAD", + CurrencyType::Gbp => "GBP", + CurrencyType::Eur => "EUR", + CurrencyType::Jpy => "JPY", } } @@ -22,13 +31,24 @@ impl CurrencyType { pub fn symbol(&self) -> &'static str { match self { CurrencyType::Usd => "$", + CurrencyType::Cad => "$", + CurrencyType::Gbp => "Β£", + CurrencyType::Eur => "€", + CurrencyType::Jpy => "Β₯", } } +} - /// Parse currency from string - pub fn from_str(s: &str) -> Result { - match s.to_uppercase().as_str() { - "USD" => Ok(CurrencyType::Usd), +impl TryFrom<&str> for CurrencyType { + type Error = String; + + fn try_from(s: &str) -> Result { + match s.to_uppercase().trim() { + "USD" | "US" | "$" => Ok(CurrencyType::Usd), + "CAD" | "CA" => Ok(CurrencyType::Cad), + "GBP" | "GB" | "Β£" => Ok(CurrencyType::Gbp), + "EUR" | "EU" | "€" => Ok(CurrencyType::Eur), + "JPY" | "JP" | "Β₯" => Ok(CurrencyType::Jpy), _ => Err(format!("Unsupported currency: {s}")), } } @@ -70,13 +90,14 @@ impl<'q> Encode<'q, Sqlite> for CurrencyType { impl<'r> Decode<'r, Sqlite> for CurrencyType { fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { let currency_str = <&str as Decode>::decode(value)?; - CurrencyType::from_str(currency_str).map_err(Into::into) + CurrencyType::try_from(currency_str).map_err(Into::into) } } #[cfg(test)] mod tests { use super::*; + use rstest::rstest; #[test] fn test_currency_type_display() { @@ -92,15 +113,20 @@ mod tests { assert_eq!(default_currency, CurrencyType::Usd); } - #[test] - fn test_currency_type_parsing() { - let parsed_currency = CurrencyType::from_str("usd").unwrap(); // Case insensitive - assert_eq!(parsed_currency, CurrencyType::Usd); + #[rstest] + #[case("usd", CurrencyType::Usd)] + #[case("USD", CurrencyType::Usd)] + #[case("USD", CurrencyType::Usd)] + #[case("CA", CurrencyType::Cad)] + fn test_currency_type_parsing(#[case] input: &str, #[case] expected: CurrencyType) { + let parsed_currency = CurrencyType::try_from(input).unwrap(); + assert_eq!(parsed_currency, expected); + } - let parsed_upper = CurrencyType::from_str("USD").unwrap(); - assert_eq!(parsed_upper, CurrencyType::Usd); - - let invalid = CurrencyType::from_str("EUR"); + #[rstest] + #[case("ASD")] + fn test_currency_type_parsing_invalid(#[case] input: &str) { + let invalid = CurrencyType::try_from(input); assert!(invalid.is_err()); } } diff --git a/src/keyboard_utils.rs b/src/keyboard_utils.rs index c21a196..ddb8228 100644 --- a/src/keyboard_utils.rs +++ b/src/keyboard_utils.rs @@ -1,19 +1,19 @@ #[macro_export] macro_rules! keyboard_buttons { ($vis:vis enum $name:ident { - $($variant:ident($text:literal, $callback_data:literal),)* + $($variant:ident($text:literal, $callback_data:literal)),* $(,)? }) => { keyboard_buttons! { $vis enum $name { - [$($variant($text, $callback_data),)*] + [$($variant($text, $callback_data)),*] } } }; ($vis:vis enum $name:ident { $([ - $($variant:ident($text:literal, $callback_data:literal),)* - ]),* + $($variant:ident($text:literal, $callback_data:literal)),* $(,)? + ]),* $(,)? }) => { #[derive(Debug, Clone, Copy, PartialEq, Eq)] $vis enum $name { diff --git a/src/message_utils.rs b/src/message_utils.rs index bb374c5..e1e81ab 100644 --- a/src/message_utils.rs +++ b/src/message_utils.rs @@ -51,10 +51,6 @@ impl<'s> From<&'s Chat> for HandleAndId<'s> { } } -pub fn is_cancel(text: &str) -> bool { - text.eq_ignore_ascii_case("/cancel") -} - #[derive(Debug, Clone)] pub struct MessageTarget { pub chat_id: ChatId,