basic listing stuff

This commit is contained in:
Dylan Knutson
2025-09-02 01:43:15 +00:00
parent da1fd1027f
commit f74c3502d6
21 changed files with 739 additions and 623 deletions

15
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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,

View File

@@ -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}"),
}
}

View File

@@ -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?;

View File

@@ -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,

View File

@@ -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(())
}

View File

@@ -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,
}

View File

@@ -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"),
]
],
}
}

View File

@@ -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()])
}

View File

@@ -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;

View File

@@ -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!(

View File

@@ -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,

View File

@@ -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?;

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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())

View File

@@ -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())
}
}

View File

@@ -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());
}
}

View File

@@ -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 {

View File

@@ -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,