basic listing stuff
This commit is contained in:
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<ListingType>::into(&listing.fields);
|
||||
let listing_id = listing.persisted.id;
|
||||
let response = format!(
|
||||
"🔍 <b>Listing Details</b>\n\n\
|
||||
"🔍 <b>{listing_type} Details</b>\n\n\
|
||||
<b>Title:</b> {}\n\
|
||||
<b>Description:</b> {}\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?;
|
||||
|
||||
@@ -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: <b>{num_slots}</b>\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: <b>immediately</b>\n\n{}",
|
||||
get_step_message(ListingField::Duration, draft.listing_type())
|
||||
"✅ Listing will start: <b>{}</b>\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: <b>{}</b>\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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<String, anyhow::Error> {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T> = Result<Option<T>, 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<String>,
|
||||
pub set_field_value: fn(&mut ListingDraft, Option<String>) -> 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<InlineKeyboardMarku
|
||||
NavKeyboardButtons::Back.to_button(),
|
||||
NavKeyboardButtons::Skip.to_button(),
|
||||
]])),
|
||||
ListingField::CurrencyType => 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()])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<PersistedListingFields>,
|
||||
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,
|
||||
|
||||
@@ -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<String>,
|
||||
) -> 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!("📋 <b>Listing Summary</b> {unsaved_changes}"));
|
||||
response_lines.push("".to_string());
|
||||
response_lines.push(format!("<b>Title:</b> {}", draft.base.title));
|
||||
response_lines.push(format!(
|
||||
"📄 <b>Description:</b> {}",
|
||||
draft
|
||||
.base
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or("<i>No description</i>")
|
||||
"📋 <b>{listing_type} Summary</b> {unsaved_changes}"
|
||||
));
|
||||
response_lines.push("".to_string());
|
||||
|
||||
if let ListingFields::FixedPriceListing(fields) = &draft.fields {
|
||||
response_lines.push(format!(
|
||||
"💰 <b>Buy it Now Price:</b> ${}",
|
||||
fields.buy_now_price
|
||||
));
|
||||
}
|
||||
|
||||
match &draft.persisted {
|
||||
ListingDraftPersisted::New(fields) => {
|
||||
response_lines.push(format!("<b>Start delay:</b> {}", fields.start_delay));
|
||||
response_lines.push(format!("<b>Duration:</b> {}", fields.end_delay));
|
||||
}
|
||||
ListingDraftPersisted::Persisted(fields) => {
|
||||
response_lines.push(format!(
|
||||
"<b>Starts on:</b> {}",
|
||||
format_datetime(fields.start_at)
|
||||
));
|
||||
response_lines.push(format!(
|
||||
"<b>Ends on:</b> {}",
|
||||
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!("<b>{}:</b> {}", 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<String>,
|
||||
) -> 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?;
|
||||
|
||||
@@ -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<T> = Result<T, SetFieldError>;
|
||||
|
||||
fn validation_failed<T>(message: &str) -> SetFieldResult<T> {
|
||||
Err(SetFieldError::ValidationFailed(message.to_string()))
|
||||
}
|
||||
macro_rules! validation_failed {
|
||||
($message:expr) => {
|
||||
return validation_failed($message)
|
||||
};
|
||||
}
|
||||
|
||||
pub fn require_field(field: Option<String>) -> SetFieldResult<String> {
|
||||
field.ok_or(SetFieldError::FieldRequired)
|
||||
}
|
||||
|
||||
// Common input validation functions
|
||||
pub fn validate_title(text: &str) -> Result<String, String> {
|
||||
pub fn validate_title(text: impl AsRef<str>) -> SetFieldResult<String> {
|
||||
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<String, String> {
|
||||
pub fn validate_description(text: impl AsRef<str>) -> SetFieldResult<String> {
|
||||
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<MoneyAmount, String> {
|
||||
pub fn validate_currency_type(text: impl AsRef<str>) -> SetFieldResult<CurrencyType> {
|
||||
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<str>) -> SetFieldResult<MoneyAmount> {
|
||||
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<i32, String> {
|
||||
pub fn validate_slots(text: impl AsRef<str>) -> SetFieldResult<i32> {
|
||||
let text = text.as_ref();
|
||||
match text.parse::<i32>() {
|
||||
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<ListingDuration, String> {
|
||||
pub fn validate_time(text: impl AsRef<str>, field_name: &str) -> SetFieldResult<Duration> {
|
||||
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::<i32>() {
|
||||
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::<i32>() {
|
||||
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<ListingDuration, String> {
|
||||
match text.parse::<i32>() {
|
||||
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<str>) -> SetFieldResult<Duration> {
|
||||
validate_time(text, "Duration")
|
||||
}
|
||||
|
||||
pub fn validate_anti_snipe_minutes(text: &str) -> Result<i32, String> {
|
||||
pub fn validate_start_time(text: impl AsRef<str>) -> SetFieldResult<DateTime<Utc>> {
|
||||
validate_time(text, "Start Time").map(|duration| Utc::now() + duration)
|
||||
}
|
||||
|
||||
pub fn validate_anti_snipe_minutes(text: impl AsRef<str>) -> SetFieldResult<i32> {
|
||||
let text = text.as_ref();
|
||||
match text.parse::<i32>() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<PersistedListing> {
|
||||
let now = Utc::now();
|
||||
let start_at = now + Into::<Duration>::into(listing.persisted.start_delay);
|
||||
let end_at = start_at + Into::<Duration>::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 {
|
||||
|
||||
@@ -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<NewListingFields>;
|
||||
pub type NewListing = Listing<()>;
|
||||
pub type PersistedListing = Listing<PersistedListingFields>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
|
||||
pub struct PersistedListingFields {
|
||||
pub id: ListingDbId,
|
||||
pub start_at: DateTime<Utc>,
|
||||
pub end_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[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<P: Debug + Clone> {
|
||||
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<ListingBaseFields<'a>> for &'a Listing<P> {
|
||||
fn into(self) -> ListingBaseFields<'a> {
|
||||
(&self.base, &self.fields)
|
||||
}
|
||||
}
|
||||
impl<'a, P: Debug + Clone> Into<ListingBaseFieldsMut<'a>> for &'a mut Listing<P> {
|
||||
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<String>,
|
||||
pub starts_at: DateTime<Utc>,
|
||||
pub ends_at: DateTime<Utc>,
|
||||
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<String>,
|
||||
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())
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Self, String> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"USD" => Ok(CurrencyType::Usd),
|
||||
impl TryFrom<&str> for CurrencyType {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(s: &str) -> Result<Self, String> {
|
||||
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<Self, BoxDynError> {
|
||||
let currency_str = <&str as Decode<Sqlite>>::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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user